diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/manager/VerifiedProtoManager.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/manager/VerifiedProtoManager.kt index ceb3565a5e..6afde85c93 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/manager/VerifiedProtoManager.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/manager/VerifiedProtoManager.kt @@ -5,6 +5,7 @@ import com.getcode.opencode.internal.network.extensions.toMint import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Rate import com.getcode.solana.keys.Mint +import com.google.protobuf.Timestamp import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map @@ -25,9 +26,12 @@ import kotlin.time.Instant * application lifecycle. */ @Singleton -class VerifiedProtoManager @Inject constructor() { +class VerifiedProtoManager internal constructor( + private val clock: Clock, +) { - private val TTL = 15.minutes + @Inject + constructor() : this(Clock.System) /** * A [MutableStateFlow] holding the latest cached exchange rate data. @@ -46,14 +50,19 @@ class VerifiedProtoManager @Inject constructor() { private val reserveStates = MutableStateFlow>(emptyMap()) fun saveRates(exchangeData: List) { - val incoming = exchangeData.mapNotNull { data -> - CurrencyCode.tryValueOf(data.exchangeRate.currencyCode)?.let { it to data } - }.toMap() + val incoming = exchangeData + .filterNot { isExpired(it.exchangeRate.timestamp) } + .mapNotNull { data -> + CurrencyCode.tryValueOf(data.exchangeRate.currencyCode)?.let { it to data } + } + .toMap() this.exchangeData.update { it + incoming } } fun saveReserveStates(reserveStates: List) { - val incoming = reserveStates.associateBy { it.reserveState.mint.toMint() } + val incoming = reserveStates + .filterNot { isExpired(it.reserveState.timestamp) } + .associateBy { it.reserveState.mint.toMint() } this.reserveStates.update { it + incoming } } @@ -100,46 +109,49 @@ class VerifiedProtoManager @Inject constructor() { reserveStates.value = emptyMap() } - private fun get(currencyCode: CurrencyCode): OcpCurrencyService.VerifiedCoreMintFiatExchangeRate? { - return exchangeData.value[currencyCode] - } - + /** + * Returns the cached rate for [currencyCode], or null when there is none or the + * cached proto is older than [MaxAge]. An expired proto is evicted so the caller + * falls through to a fresh fetch instead of submitting a proof the server will reject. + */ private fun getOrEvict(currencyCode: CurrencyCode): OcpCurrencyService.VerifiedCoreMintFiatExchangeRate? { - val now = Clock.System.now() - val stored = get(currencyCode) ?: return null - val ts = Instant.fromEpochSeconds(stored.exchangeRate.timestamp.seconds, stored.exchangeRate.timestamp.nanos) - val expired = now - ts > TTL - if (expired) { - val updated = exchangeData.value.filterNot { it.key == currencyCode } - exchangeData.update { updated } + val stored = exchangeData.value[currencyCode] ?: return null + if (isExpired(stored.exchangeRate.timestamp)) { + exchangeData.update { it - currencyCode } + return null } - return stored } - private fun get(mint: Mint): OcpCurrencyService.VerifiedLaunchpadCurrencyReserveState? { - return reserveStates.value[mint] - } - private fun getOrEvict(mint: Mint): OcpCurrencyService.VerifiedLaunchpadCurrencyReserveState? { - val now = Clock.System.now() - val stored = get(mint) ?: return null - val ts = Instant.fromEpochSeconds(stored.reserveState.timestamp.seconds, stored.reserveState.timestamp.nanos) - val expired = now - ts > TTL - if (expired) { - val updated = reserveStates.value.filterNot { it.key == mint } - reserveStates.update { updated } + val stored = reserveStates.value[mint] ?: return null + if (isExpired(stored.reserveState.timestamp)) { + reserveStates.update { it - mint } + return null } - return stored } + private fun isExpired(timestamp: Timestamp): Boolean { + val signedAt = Instant.fromEpochSeconds(timestamp.seconds, timestamp.nanos) + return clock.now() - signedAt > MaxAge + } + fun getVerifiedStateFor(currencyCode: CurrencyCode, mint: Mint): VerifiedState? { val exchangeRate = getOrEvict(currencyCode) ?: return null val reserveState = getOrEvict(mint) return VerifiedState(exchangeRate, reserveState) } + + companion object { + /** + * Oldest server-signed proto the client will submit. The server rejects proofs + * older than 15 minutes with `STALE_STATE`; 13 minutes matches the iOS + * `clientMaxAge` and leaves headroom for the request to land. + */ + private val MaxAge = 13.minutes + } } data class VerifiedState( diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt index 12d473753d..8149025165 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculatorTest.kt @@ -22,6 +22,7 @@ import com.getcode.opencode.model.financial.VmMetadata import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -57,6 +58,28 @@ class RealVerifiedFiatCalculatorTest { calculator = RealVerifiedFiatCalculator(verifiedStateManager, currencyController) } + // region resolveVerifiedState + + @Test + fun `resolveVerifiedState fetches live mint data when the cache has no usable state`() = runTest { + val refreshed = VerifiedState( + rateProto = verifiedCoreMintFiatExchangeRate { + exchangeRate = coreMintFiatExchangeRate { currencyCode = CurrencyCode.USD.name } + }, + reserveProto = verifiedLaunchpadCurrencyReserveState { + reserveState = launchpadCurrencyReserveState { supplyFromBonding = 1_000L } + }, + ) + every { verifiedStateManager.getVerifiedStateFor(CurrencyCode.USD, testMint) } returnsMany listOf(null, refreshed) + + val result = calculator.resolveVerifiedState(CurrencyCode.USD, testMint) + + coVerify(exactly = 1) { currencyController.getLiveMintData(any(), testMint, any()) } + assertEquals(refreshed, result) + } + + // endregion + // region USDF passthrough @Test diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/manager/VerifiedProtoManagerTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/manager/VerifiedProtoManagerTest.kt index 6383ffb4c9..fce03eeb3a 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/internal/manager/VerifiedProtoManagerTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/internal/manager/VerifiedProtoManagerTest.kt @@ -1,12 +1,17 @@ package com.getcode.opencode.internal.manager import app.cash.turbine.test +import com.codeinc.opencode.gen.common.v1.solanaAccountId import com.codeinc.opencode.gen.currency.v1.OcpCurrencyService import com.codeinc.opencode.gen.currency.v1.coreMintFiatExchangeRate +import com.codeinc.opencode.gen.currency.v1.launchpadCurrencyReserveState import com.codeinc.opencode.gen.currency.v1.verifiedCoreMintFiatExchangeRate +import com.codeinc.opencode.gen.currency.v1.verifiedLaunchpadCurrencyReserveState import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Rate import com.getcode.solana.keys.Mint +import com.google.protobuf.ByteString +import com.google.protobuf.Timestamp import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -14,14 +19,25 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant class VerifiedProtoManagerTest { + private class FakeClock(var current: Instant) : Clock { + override fun now(): Instant = current + } + + private val start = Instant.fromEpochSeconds(1_757_433_600) + private lateinit var clock: FakeClock private lateinit var manager: VerifiedProtoManager @Before fun setUp() { - manager = VerifiedProtoManager() + clock = FakeClock(start) + manager = VerifiedProtoManager(clock) } // region saveRates / getVerifiedStateFor @@ -173,19 +189,116 @@ class VerifiedProtoManagerTest { // endregion + // region expiry + + @Test + fun `getVerifiedStateFor returns state while the rate is at most 13 minutes old`() { + manager.saveRates(listOf(rateProto("USD"))) + clock.current = start + 13.minutes + + assertNotNull(manager.getVerifiedStateFor(CurrencyCode.USD, Mint.usdf)) + } + + @Test + fun `getVerifiedStateFor returns null once the rate is older than 13 minutes`() { + manager.saveRates(listOf(rateProto("USD"))) + clock.current = start + 13.minutes + 1.seconds + + assertNull(manager.getVerifiedStateFor(CurrencyCode.USD, Mint.usdf)) + } + + @Test + fun `getVerifiedStateFor evicts an expired rate from the cache`() { + manager.saveRates(listOf(rateProto("USD", fx = 1.0))) + clock.current = start + 14.minutes + + manager.getVerifiedStateFor(CurrencyCode.USD, Mint.usdf) + + assertNull(manager.rateFor(CurrencyCode.USD)) + } + + @Test + fun `getVerifiedStateFor drops an expired reserve state but keeps a fresh rate`() { + val mint = Mint(List(32) { 7.toByte() }) + manager.saveRates(listOf(rateProto("USD"))) + manager.saveReserveStates(listOf(reserveProto(mint))) + clock.current = start + 10.minutes + manager.saveRates(listOf(rateProto("USD"))) + clock.current = start + 14.minutes + + val state = manager.getVerifiedStateFor(CurrencyCode.USD, mint) + + assertNotNull(state) + assertNull(state.reserveProto) + } + + @Test + fun `saveRates ignores a rate that is already older than 13 minutes`() { + manager.saveRates(listOf(rateProto("USD", fx = 1.0, timestamp = start - 14.minutes))) + + assertNull(manager.getVerifiedStateFor(CurrencyCode.USD, Mint.usdf)) + assertNull(manager.rateFor(CurrencyCode.USD)) + } + + @Test + fun `saveRates keeps the fresh rates in a batch that also contains an expired one`() { + manager.saveRates( + listOf( + rateProto("USD", fx = 1.0, timestamp = start - 14.minutes), + rateProto("EUR", fx = 0.85), + ) + ) + + assertNull(manager.rateFor(CurrencyCode.USD)) + assertEquals(Rate(fx = 0.85, currency = CurrencyCode.EUR), manager.rateFor(CurrencyCode.EUR)) + } + + @Test + fun `saveReserveStates ignores a reserve state that is already older than 13 minutes`() { + val mint = Mint(List(32) { 7.toByte() }) + manager.saveRates(listOf(rateProto("USD"))) + manager.saveReserveStates(listOf(reserveProto(mint, timestamp = start - 14.minutes))) + + val state = manager.getVerifiedStateFor(CurrencyCode.USD, mint) + + assertNotNull(state) + assertNull(state.reserveProto) + } + + // endregion + // region helpers private fun rateProto( code: String, fx: Double = 0.0, + timestamp: Instant = clock.now(), ): OcpCurrencyService.VerifiedCoreMintFiatExchangeRate { return verifiedCoreMintFiatExchangeRate { exchangeRate = coreMintFiatExchangeRate { currencyCode = code exchangeRate = fx + this.timestamp = timestamp.toProto() } } } + private fun reserveProto( + mint: Mint, + timestamp: Instant = clock.now(), + ): OcpCurrencyService.VerifiedLaunchpadCurrencyReserveState { + return verifiedLaunchpadCurrencyReserveState { + reserveState = launchpadCurrencyReserveState { + this.mint = solanaAccountId { value = ByteString.copyFrom(mint.bytes.toByteArray()) } + this.timestamp = timestamp.toProto() + } + } + } + + private fun Instant.toProto(): Timestamp = Timestamp.newBuilder() + .setSeconds(epochSeconds) + .setNanos(nanosecondsOfSecond) + .build() + // endregion }