Skip to content

Commit d875270

Browse files
committed
fix(ocp): add more resilience around verified state fetching during gives
don't blindly accept the result from proto manager, we now audit it including reserve state when needed and calling for it ourselves. This is specifically useful when claiming cash links for new (to user) tokens and we aren't yet streaming the mint data for this token yet. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 8433249 commit d875270

3 files changed

Lines changed: 87 additions & 26 deletions

File tree

services/opencode/src/main/kotlin/com/getcode/opencode/controllers/CurrencyController.kt

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,25 @@ import kotlinx.coroutines.flow.callbackFlow
1515
import kotlinx.coroutines.flow.emptyFlow
1616
import kotlinx.coroutines.flow.first
1717
import kotlinx.coroutines.flow.flatMapLatest
18+
import kotlinx.coroutines.flow.scan
1819
import kotlinx.coroutines.flow.shareIn
1920
import kotlinx.coroutines.launch
20-
import kotlinx.coroutines.suspendCancellableCoroutine
2121
import javax.inject.Inject
2222
import javax.inject.Singleton
23-
import kotlin.coroutines.resume
2423

24+
/**
25+
* Provides access to live and historical mint/currency data backed by
26+
* server-streamed updates and one-shot RPCs.
27+
*/
2528
@Singleton
2629
class CurrencyController @Inject constructor(
2730
private val repository: CurrencyRepository,
2831
) {
32+
/**
33+
* Returns a long-lived [Flow] of [LiveMintDataResponse] events for the
34+
* given [mints]. The flow re-subscribes whenever the mint list changes
35+
* and replays the most recent emission to late collectors.
36+
*/
2937
fun streamLiveMintData(
3038
scope: CoroutineScope,
3139
mints: Flow<List<Mint>>,
@@ -53,25 +61,48 @@ class CurrencyController @Inject constructor(
5361
}
5462
}
5563

64+
/**
65+
* Opens a short-lived mint data stream and suspends until all required data
66+
* has been received and persisted to the proto store.
67+
*
68+
* For most mints this waits for both exchange rates and launchpad reserve
69+
* state. USDF only requires exchange rates (no reserve state exists).
70+
*/
5671
suspend fun getLiveMintData(
5772
scope: CoroutineScope,
5873
mint: Mint,
5974
tag: String? = null
60-
): Result<LiveMintDataResponse> = runCatching {
75+
): Result<Unit> = runCatching {
76+
val needsReserveState = mint != Mint.usdf
77+
6178
callbackFlow {
6279
val reference = repository.streamMintData(scope = scope, mints = listOf(mint), tag = tag) {
6380
trySend(it)
6481
}
6582
awaitClose { reference.cancel() }
66-
}.first()
83+
}.scan(emptySet<Class<out LiveMintDataResponse>>()) { seen, response ->
84+
seen + response::class.java
85+
}.first { seen ->
86+
seen.contains(LiveMintDataResponse.ExchangeRates::class.java) &&
87+
(!needsReserveState || seen.contains(LiveMintDataResponse.LaunchpadReserveState::class.java))
88+
}
6789
}
6890

91+
92+
/**
93+
* Fetches static metadata (name, symbol, decimals, etc.) for the given
94+
* mint [addresses] in a single RPC call.
95+
*/
6996
suspend fun getMintMetadata(
7097
addresses: List<Mint>
7198
): Result<List<MintMetadata>> {
7299
return repository.getMintMetadata(addresses)
73100
}
74101

102+
/**
103+
* Fetches historical price/exchange data for [mint] denominated in
104+
* [currencyCode] over the specified [windowedRange].
105+
*/
75106
suspend fun getHistoricalMintData(
76107
mint: Mint,
77108
currencyCode: CurrencyCode,

services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/CurrencyService.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,14 @@ import com.getcode.opencode.internal.network.api.CurrencyApi
1111
import com.getcode.opencode.internal.network.extensions.foldWithSuppression
1212
import com.getcode.opencode.internal.network.streamers.LiveMintDataStreamer
1313
import com.getcode.opencode.internal.network.streamers.ManagedMintStream
14-
import com.getcode.opencode.internal.network.streamers.OcpMintStreamingReference
1514
import com.getcode.opencode.model.core.errors.GetHistoricalMintDataError
1615
import com.getcode.opencode.model.core.errors.GetMintsError
17-
import com.getcode.opencode.model.core.errors.GetRatesError
1816
import com.getcode.opencode.model.financial.CurrencyCode
1917
import com.getcode.opencode.model.financial.HistoricalMintData
2018
import com.getcode.opencode.model.financial.MintMetadata
21-
import com.getcode.opencode.model.financial.Rate
2219
import com.getcode.solana.keys.Mint
2320
import com.getcode.solana.keys.PublicKey
2421
import kotlinx.coroutines.CoroutineScope
25-
import kotlinx.datetime.Instant
2622
import javax.inject.Inject
2723

2824
internal class CurrencyService @Inject constructor(

services/opencode/src/main/kotlin/com/getcode/opencode/internal/transactors/GiveBillTransactor.kt

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.getcode.opencode.model.financial.LocalFiat
1616
import com.getcode.opencode.model.financial.Token
1717
import com.getcode.opencode.model.transactions.TransactionMetadata
1818
import com.getcode.opencode.utils.nonce
19+
import com.getcode.solana.keys.Mint
1920
import com.getcode.solana.keys.PublicKey
2021
import com.getcode.utils.CodeServerError
2122
import com.getcode.utils.TraceType
@@ -106,15 +107,7 @@ internal class GiveBillTransactor(
106107
val sendingAmount = amount
107108
?: return logAndFail(GiveTransactorError.Other(message = "No amount. Did you call with() first?"))
108109

109-
// Resolve the verified state for the given currency/token pair using a fallback chain:
110-
// 1. Use the provided state directly if available
111-
// 2. Otherwise, check the local proto store
112-
// 3. If still missing, fetch live mint data (which internally persists to the store) and re-query
113-
val verifiedState = providedVerifiedState
114-
?: verifiedProtoManager.getVerifiedStateFor(sendingAmount.rate.currency, desiredToken.address)
115-
?: currencyController.getLiveMintData(scope, desiredToken.address)
116-
.map { verifiedProtoManager.getVerifiedStateFor(sendingAmount.rate.currency, desiredToken.address) }
117-
.getOrNull()
110+
val verifiedState = resolveVerifiedState(sendingAmount, desiredToken)
118111
?: return logAndFail(GiveTransactorError.Other("Failed to get verified state"))
119112

120113
val exchangeData = verifiedState.exchangeDataFor(
@@ -173,15 +166,8 @@ internal class GiveBillTransactor(
173166

174167
receivingAccount = transferRequest.account
175168

176-
/**
177-
* At transfer time:
178-
* 1. If providedVerifiedState exists → use it (caller knows best)
179-
* 2. Otherwise → try fresh from proto store
180-
* 3. Otherwise → fall back to the upfront-resolved state
181-
*/
182-
val transferVerifiedState = providedVerifiedState
183-
?: verifiedProtoManager.getVerifiedStateFor(sendingAmount.rate.currency, desiredToken.address)
184-
?: verifiedState
169+
val transferVerifiedState = resolveVerifiedState(sendingAmount, desiredToken)
170+
?: return logAndFail(GiveTransactorError.Other("Failed to get verified state"))
185171

186172
val transferExchangeData = transferVerifiedState.exchangeDataFor(
187173
amount = sendingAmount,
@@ -223,6 +209,54 @@ internal class GiveBillTransactor(
223209
scope.cancel()
224210
}
225211

212+
/**
213+
* Resolve the verified state for the given currency/token pair using a fallback chain:
214+
* 1. Use the provided state directly if available
215+
* 2. Otherwise, check the local proto store
216+
* 3. If still missing, fetch live mint data (which internally persists to the store) and re-query
217+
*/
218+
private suspend fun resolveVerifiedState(
219+
sendingAmount: LocalFiat,
220+
desiredToken: Token
221+
): VerifiedState? {
222+
val currency = sendingAmount.rate.currency
223+
val mint = desiredToken.address
224+
val label = "${currency}/${desiredToken.symbol}"
225+
val needsReserveState = mint != Mint.usdf
226+
227+
providedVerifiedState?.let {
228+
trace(tag = tag, message = "Using provided verified state for $label")
229+
return it
230+
}
231+
232+
verifiedProtoManager.getVerifiedStateFor(currency, mint)?.let {
233+
if (!needsReserveState || it.reserveProto != null) {
234+
trace(tag = tag, message = "Resolved verified state from proto store for $label")
235+
return it
236+
}
237+
238+
trace(tag = tag, message = "Proto store hit but missing reserve state for $label — fetching live mint data")
239+
}
240+
241+
trace(tag = tag, message = "Proto store miss — fetching live mint data for ${desiredToken.symbol}")
242+
243+
return currencyController.getLiveMintData(scope, mint)
244+
.onFailure { cause ->
245+
trace(
246+
tag = tag,
247+
message = "Live mint data fetch failed for ${desiredToken.symbol}",
248+
type = TraceType.Error,
249+
error = cause
250+
)
251+
}
252+
.map { verifiedProtoManager.getVerifiedStateFor(currency, mint) }
253+
.getOrNull()
254+
?.also {
255+
trace(tag = tag, message = "Resolved verified state after live mint fetch for $label")
256+
}
257+
}
258+
259+
226260
sealed class GiveTransactorError(
227261
override val message: String? = null,
228262
override val cause: Throwable? = null

0 commit comments

Comments
 (0)