Updated on 2026-08-14
This commit is contained in:
parent
7b4593acee
commit
223051955f
22 changed files with 606 additions and 95 deletions
|
|
@ -1,29 +1,89 @@
|
|||
package com.tangem.tap.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.offramp.model.PendingOfframp
|
||||
import com.tangem.domain.offramp.repository.OfframpRepository
|
||||
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
|
||||
import com.tangem.tap.data.converter.PendingOfframpEntryConverter
|
||||
import com.tangem.tap.data.model.PendingOfframpEntry
|
||||
import com.tangem.tap.network.exchangeServices.SellService
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Default implementation of [OfframpRepository]
|
||||
* Default implementation of [OfframpRepository].
|
||||
*
|
||||
* @property sellService sell service for getting offramp URL
|
||||
* @property pendingOfframpStore dedicated kotlinx-serialized store of app-initiated sells
|
||||
* @property dispatchers coroutine dispatchers provider for IO operations
|
||||
*/
|
||||
internal class DefaultOfframpRepository(
|
||||
private val sellService: SellService,
|
||||
private val pendingOfframpStore: DataStore<List<PendingOfframpEntry>>,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : OfframpRepository {
|
||||
|
||||
private val pendingOfframpConverter = PendingOfframpEntryConverter()
|
||||
|
||||
override fun getOfframpUrl(
|
||||
cryptoCurrency: CryptoCurrency,
|
||||
fiatCurrencyCode: String,
|
||||
walletAddress: String,
|
||||
requestId: String,
|
||||
): String? {
|
||||
return sellService.getUrl(
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
fiatCurrencyName = fiatCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
isDarkTheme = MutableAppThemeModeHolder.isDarkThemeActive,
|
||||
requestId = requestId,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun registerPendingOfframp(userWalletId: UserWalletId, currencyId: String): String =
|
||||
withContext(dispatchers.io) {
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val now = System.currentTimeMillis()
|
||||
pendingOfframpStore.updateData { stored ->
|
||||
stored.filterNotExpired(now) + PendingOfframpEntry(
|
||||
requestId = requestId,
|
||||
userWalletId = userWalletId.stringValue,
|
||||
currencyId = currencyId,
|
||||
createdAt = now,
|
||||
)
|
||||
}
|
||||
requestId
|
||||
}
|
||||
|
||||
override suspend fun consumePendingOfframp(
|
||||
requestId: String,
|
||||
userWalletId: UserWalletId,
|
||||
currencyId: String,
|
||||
): PendingOfframp? = withContext(dispatchers.io) {
|
||||
val now = System.currentTimeMillis()
|
||||
var matched: PendingOfframpEntry? = null
|
||||
pendingOfframpStore.updateData { stored ->
|
||||
matched = stored.firstOrNull { entry ->
|
||||
entry.requestId == requestId &&
|
||||
entry.userWalletId == userWalletId.stringValue &&
|
||||
entry.currencyId == currencyId &&
|
||||
now - entry.createdAt < EXPIRY_MS
|
||||
}
|
||||
// Remove only the fully-matched record (single-use); always prune expired ones. A request_id that
|
||||
// matches but with a mismatched wallet/currency is left intact so a tampered redirect cannot burn it.
|
||||
stored.filter { it != matched }.filterNotExpired(now)
|
||||
}
|
||||
matched?.let(pendingOfframpConverter::convert)
|
||||
}
|
||||
|
||||
private fun List<PendingOfframpEntry>.filterNotExpired(now: Long): List<PendingOfframpEntry> =
|
||||
filter { now - it.createdAt < EXPIRY_MS }
|
||||
|
||||
private companion object {
|
||||
val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.tap.data.converter
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.offramp.model.PendingOfframp
|
||||
import com.tangem.tap.data.model.PendingOfframpEntry
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
/**
|
||||
* Converts a persisted [PendingOfframpEntry] into the domain [PendingOfframp].
|
||||
*/
|
||||
internal class PendingOfframpEntryConverter : Converter<PendingOfframpEntry, PendingOfframp> {
|
||||
|
||||
override fun convert(value: PendingOfframpEntry): PendingOfframp = PendingOfframp(
|
||||
requestId = value.requestId,
|
||||
userWalletId = UserWalletId(stringValue = value.userWalletId),
|
||||
currencyId = value.currencyId,
|
||||
createdAt = value.createdAt,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.tangem.tap.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Persisted entry of an app-initiated sell (off-ramp) flow, stored in a dedicated kotlinx-serialized DataStore.
|
||||
*
|
||||
* [userWalletId] holds the [com.tangem.domain.models.wallet.UserWalletId.stringValue].
|
||||
*
|
||||
* @see com.tangem.domain.offramp.model.PendingOfframp
|
||||
*/
|
||||
@Serializable
|
||||
internal data class PendingOfframpEntry(
|
||||
@SerialName("requestId")
|
||||
val requestId: String,
|
||||
@SerialName("userWalletId")
|
||||
val userWalletId: String,
|
||||
@SerialName("currencyId")
|
||||
val currencyId: String,
|
||||
@SerialName("createdAt")
|
||||
val createdAt: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.tangem.tap.di.domain
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.dataStoreFile
|
||||
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
|
||||
import com.tangem.domain.offramp.GetOfframpUrlUseCase
|
||||
import com.tangem.domain.offramp.repository.OfframpRepository
|
||||
import com.tangem.tap.data.DefaultOfframpRepository
|
||||
import com.tangem.tap.data.model.PendingOfframpEntry
|
||||
import com.tangem.tap.network.exchangeServices.SellService
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object OfframpDomainModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePendingOfframpStore(
|
||||
@ApplicationContext context: Context,
|
||||
appScope: AppCoroutineScope,
|
||||
): DataStore<List<PendingOfframpEntry>> = DataStoreFactory.create(
|
||||
serializer = KotlinxDataStoreSerializer(
|
||||
defaultValue = emptyList(),
|
||||
serializer = ListSerializer(PendingOfframpEntry.serializer()),
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = "pending_offramps") },
|
||||
scope = appScope,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOfframpRepository(
|
||||
sellService: SellService,
|
||||
pendingOfframpStore: DataStore<List<PendingOfframpEntry>>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): OfframpRepository {
|
||||
return DefaultOfframpRepository(sellService, pendingOfframpStore, dispatchers)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase {
|
||||
return GetOfframpUrlUseCase(offrampRepository)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,8 @@
|
|||
package com.tangem.tap.di.domain
|
||||
|
||||
import com.tangem.domain.offramp.GetOfframpUrlUseCase
|
||||
import com.tangem.domain.offramp.repository.OfframpRepository
|
||||
import com.tangem.domain.onramp.*
|
||||
import com.tangem.domain.onramp.repositories.*
|
||||
import com.tangem.domain.settings.repositories.SettingsRepository
|
||||
import com.tangem.tap.data.DefaultOfframpRepository
|
||||
import com.tangem.tap.network.exchangeServices.SellService
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -270,16 +266,4 @@ internal object OnrampDomainModule {
|
|||
settingsRepository = settingsRepository,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOfframpRepository(sellService: SellService): OfframpRepository {
|
||||
return DefaultOfframpRepository(sellService)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase {
|
||||
return GetOfframpUrlUseCase(offrampRepository)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,5 +20,6 @@ interface SellService {
|
|||
fiatCurrencyName: String,
|
||||
walletAddress: String,
|
||||
isDarkTheme: Boolean,
|
||||
requestId: String,
|
||||
): String?
|
||||
}
|
||||
|
|
@ -138,6 +138,7 @@ class MoonPayService(
|
|||
fiatCurrencyName: String,
|
||||
walletAddress: String,
|
||||
isDarkTheme: Boolean,
|
||||
requestId: String,
|
||||
): String? {
|
||||
val blockchain = cryptoCurrency.network.toBlockchain()
|
||||
if (blockchain.isTestnet()) return blockchain.getTestnetTopUpUrl()
|
||||
|
|
@ -165,7 +166,12 @@ class MoonPayService(
|
|||
.appendQueryParameter("apiKey", apiKey)
|
||||
.appendQueryParameter("baseCurrencyCode", moonpayCurrency.currencyCode.uppercase())
|
||||
.appendQueryParameter("refundWalletAddress", walletAddress)
|
||||
.appendQueryParameter("redirectURL", "tangem://redirect_sell?currency_id=${cryptoCurrency.id.value}")
|
||||
// request_id authenticates the returning redirect_sell deeplink. It must be added to
|
||||
// redirectURL BEFORE createSignature below so it is covered by the MoonPay URL signature.
|
||||
.appendQueryParameter(
|
||||
"redirectURL",
|
||||
"tangem://redirect_sell?currency_id=${cryptoCurrency.id.value}&request_id=$requestId",
|
||||
)
|
||||
|
||||
if (isDarkTheme) uri.appendQueryParameter("theme", "dark")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,54 @@
|
|||
package com.tangem.tap.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
|
||||
import com.tangem.tap.data.model.PendingOfframpEntry
|
||||
import com.tangem.tap.network.exchangeServices.SellService
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultOfframpRepositoryTest {
|
||||
|
||||
private val sellService: SellService = mockk()
|
||||
private val repository = DefaultOfframpRepository(sellService)
|
||||
private val pendingStoreState = MutableStateFlow<List<PendingOfframpEntry>>(emptyList())
|
||||
private val pendingOfframpStore = object : DataStore<List<PendingOfframpEntry>> {
|
||||
override val data = pendingStoreState
|
||||
override suspend fun updateData(
|
||||
transform: suspend (t: List<PendingOfframpEntry>) -> List<PendingOfframpEntry>,
|
||||
): List<PendingOfframpEntry> {
|
||||
val updated = transform(pendingStoreState.value)
|
||||
pendingStoreState.value = updated
|
||||
return updated
|
||||
}
|
||||
}
|
||||
private val repository = DefaultOfframpRepository(
|
||||
sellService = sellService,
|
||||
pendingOfframpStore = pendingOfframpStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
private val cryptoCurrency: CryptoCurrency = mockk()
|
||||
private val fiatCurrencyCode = "USD"
|
||||
private val walletAddress = "0x1234567890abcdef"
|
||||
private val requestId = "request-id-001"
|
||||
private val userWalletId = UserWalletId("0011223344556677")
|
||||
private val currencyId = "bitcoin"
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockkObject(MutableAppThemeModeHolder)
|
||||
pendingStoreState.value = emptyList()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
|
@ -42,6 +68,7 @@ internal class DefaultOfframpRepositoryTest {
|
|||
fiatCurrencyName = fiatCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
isDarkTheme = false,
|
||||
requestId = requestId,
|
||||
)
|
||||
} returns expectedUrl
|
||||
|
||||
|
|
@ -50,17 +77,18 @@ internal class DefaultOfframpRepositoryTest {
|
|||
cryptoCurrency = cryptoCurrency,
|
||||
fiatCurrencyCode = fiatCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
requestId = requestId,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(expectedUrl)
|
||||
|
||||
verify(exactly = 1) {
|
||||
sellService.getUrl(
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
fiatCurrencyName = fiatCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
isDarkTheme = false,
|
||||
requestId = requestId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +104,7 @@ internal class DefaultOfframpRepositoryTest {
|
|||
fiatCurrencyName = fiatCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
isDarkTheme = true,
|
||||
requestId = requestId,
|
||||
)
|
||||
} returns expectedUrl
|
||||
|
||||
|
|
@ -84,17 +113,18 @@ internal class DefaultOfframpRepositoryTest {
|
|||
cryptoCurrency = cryptoCurrency,
|
||||
fiatCurrencyCode = fiatCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
requestId = requestId,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(expectedUrl)
|
||||
|
||||
verify(exactly = 1) {
|
||||
sellService.getUrl(
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
fiatCurrencyName = fiatCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
isDarkTheme = true,
|
||||
requestId = requestId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -109,6 +139,7 @@ internal class DefaultOfframpRepositoryTest {
|
|||
fiatCurrencyName = fiatCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
isDarkTheme = false,
|
||||
requestId = requestId,
|
||||
)
|
||||
} returns null
|
||||
|
||||
|
|
@ -117,18 +148,92 @@ internal class DefaultOfframpRepositoryTest {
|
|||
cryptoCurrency = cryptoCurrency,
|
||||
fiatCurrencyCode = fiatCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
requestId = requestId,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNull()
|
||||
|
||||
verify(exactly = 1) {
|
||||
sellService.getUrl(
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
fiatCurrencyName = fiatCurrencyCode,
|
||||
walletAddress = walletAddress,
|
||||
isDarkTheme = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN registered pending offramp WHEN consume with matching wallet and currency THEN returns record`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
|
||||
|
||||
// Act
|
||||
val pending = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId)
|
||||
|
||||
// Assert
|
||||
assertThat(pending).isNotNull()
|
||||
assertThat(pending?.requestId).isEqualTo(storedRequestId)
|
||||
assertThat(pending?.userWalletId).isEqualTo(userWalletId)
|
||||
assertThat(pending?.currencyId).isEqualTo(currencyId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unknown request id WHEN consume THEN returns null`() = runTest {
|
||||
repository.registerPendingOfframp(userWalletId, currencyId)
|
||||
|
||||
assertThat(repository.consumePendingOfframp("unknown", userWalletId, currencyId)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN already consumed pending offramp WHEN consume again THEN returns null`() = runTest {
|
||||
// Arrange
|
||||
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
|
||||
|
||||
// Act
|
||||
val first = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId)
|
||||
val second = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId)
|
||||
|
||||
// Assert
|
||||
assertThat(first).isNotNull()
|
||||
assertThat(second).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN mismatched currency WHEN consume THEN returns null and does NOT burn the pending sell`() = runTest {
|
||||
// Arrange
|
||||
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
|
||||
|
||||
// Act — a tampered redirect with the right request_id but a wrong currency must not consume the token
|
||||
val mismatched = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId = "ethereum")
|
||||
// ...so the legitimate redirect can still succeed afterwards
|
||||
val legitimate = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId)
|
||||
|
||||
// Assert
|
||||
assertThat(mismatched).isNull()
|
||||
assertThat(legitimate).isNotNull()
|
||||
assertThat(legitimate?.currencyId).isEqualTo(currencyId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN mismatched wallet WHEN consume THEN returns null`() = runTest {
|
||||
val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId)
|
||||
|
||||
val result = repository.consumePendingOfframp(storedRequestId, UserWalletId("ffeeddccbbaa9988"), currencyId)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN expired pending offramp WHEN consume THEN returns null`() = runTest {
|
||||
// Arrange — seed a record created 2 hours ago (past the 1h expiry)
|
||||
val expiredId = "expired-id"
|
||||
pendingStoreState.value = listOf(
|
||||
PendingOfframpEntry(
|
||||
requestId = expiredId,
|
||||
userWalletId = userWalletId.stringValue,
|
||||
currencyId = currencyId,
|
||||
createdAt = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
val pending = repository.consumePendingOfframp(expiredId, userWalletId, currencyId)
|
||||
|
||||
// Assert
|
||||
assertThat(pending).isNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.tangem.tap.data.converter
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.offramp.model.PendingOfframp
|
||||
import com.tangem.tap.data.model.PendingOfframpEntry
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class PendingOfframpEntryConverterTest {
|
||||
|
||||
private val converter = PendingOfframpEntryConverter()
|
||||
|
||||
@Test
|
||||
fun `GIVEN entry WHEN convert THEN maps all fields and wraps wallet id`() {
|
||||
// Arrange
|
||||
val entry = PendingOfframpEntry(
|
||||
requestId = "request-id-001",
|
||||
userWalletId = "0011223344556677",
|
||||
currencyId = "bitcoin",
|
||||
createdAt = 1_700_000_000_000L,
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = converter.convert(entry)
|
||||
|
||||
// Assert
|
||||
val expected = PendingOfframp(
|
||||
requestId = "request-id-001",
|
||||
userWalletId = UserWalletId(stringValue = "0011223344556677"),
|
||||
currencyId = "bitcoin",
|
||||
createdAt = 1_700_000_000_000L,
|
||||
)
|
||||
assertThat(result).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN entries WHEN convertList THEN converts each preserving order`() {
|
||||
// Arrange
|
||||
val entries = listOf(
|
||||
PendingOfframpEntry("id-1", "0011", "bitcoin", 1L),
|
||||
PendingOfframpEntry("id-2", "0022", "ethereum", 2L),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = converter.convertList(entries)
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(
|
||||
PendingOfframp("id-1", UserWalletId("0011"), "bitcoin", 1L),
|
||||
PendingOfframp("id-2", UserWalletId("0022"), "ethereum", 2L),
|
||||
).inOrder()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue