Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -46,14 +50,19 @@ class VerifiedProtoManager @Inject constructor() {
private val reserveStates = MutableStateFlow<Map<Mint, OcpCurrencyService.VerifiedLaunchpadCurrencyReserveState>>(emptyMap())

fun saveRates(exchangeData: List<OcpCurrencyService.VerifiedCoreMintFiatExchangeRate>) {
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<OcpCurrencyService.VerifiedLaunchpadCurrencyReserveState>) {
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 }
}

Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,43 @@
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
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
Expand Down Expand Up @@ -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
}
Loading