diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 28c91eb6aa..2d545745dd 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -1319,27 +1319,27 @@ internal class ChatViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnViewerStateResolved(it)) } .launchIn(viewModelScope) - // The cache starts empty and fills in, so this is observed rather than read once — a - // requirement resolved against an empty cache would render its amount with no token beside - // it and never correct itself. - combine( - stateFlow.map { (it.subject as? ChatSubject.Group)?.rules.balanceRequirement() } - .distinctUntilChanged(), - tokenCoordinator.observeTokenCache(), - ) { requirement, tokens -> - // The name rather than the symbol, matching the create form's own mint row: the two - // screens name one holding, and the balance list a requirement is satisfied from - // renders the name as well (`TokenWithBalance.displayName`). `brandedName` is the same - // rule the link cards use, so the reserve reads "Dollars" here too rather than "USDF". - requirement?.mints?.firstOrNull() - ?.let { tokens[Mint(it.bytes)] } - ?.let { token -> + // Observed rather than read once: the rule can change under an open screen, and a buy + // hydrates a fresher copy of the token. See observeRuleToken for why it fetches as well. + stateFlow.map { state -> + (state.subject as? ChatSubject.Group)?.rules.balanceRequirement() + ?.mints?.firstOrNull()?.let { Mint(it.bytes) } + } + .distinctUntilChanged() + .flatMapLatest { mint -> mint?.let { tokenCoordinator.observeRuleToken(it) } ?: flowOf(null) } + .map { token -> + // The name rather than the symbol, matching the create form's own mint row: the two + // screens name one holding, and the balance list a requirement is satisfied from + // renders the name as well (`TokenWithBalance.displayName`). `brandedName` is the + // same rule the link cards use, so the reserve reads "Dollars" here too rather than + // "USDF". + token?.let { RuleCurrency( - name = token.brandedName(resources), - isReserve = token.isReserve, + name = it.brandedName(resources), + isReserve = it.isReserve, ) } - } + } .distinctUntilChanged() .onEach { dispatchEvent(Event.OnRuleCurrencyResolved(it)) } .launchIn(viewModelScope) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/RuleToken.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/RuleToken.kt new file mode 100644 index 0000000000..f0a461819b --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/RuleToken.kt @@ -0,0 +1,21 @@ +package com.flipcash.app.messenger.internal + +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.providers.TokenMetadataProvider +import com.getcode.solana.keys.Mint +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.mapNotNull + +/** + * The token a balance rule names, for the gate bar's "Buy More ". + * + * Fetched first rather than only observed: the cache holds only mints the account has an account + * for, so a viewer who has never held the rule's token would wait on it forever and Buy More would + * never enable. The cache is followed after the fetch so the copy hydrated by a buy replaces it. + */ +internal fun TokenMetadataProvider.observeRuleToken(mint: Mint): Flow = flow { + getTokenMetadata(mint).getOrNull()?.token?.let { emit(it) } + emitAll(observeTokenCache().mapNotNull { it[mint] }) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 648b74aaf2..97a8a422ab 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -86,6 +86,12 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } + ChatAction.AddCash -> { + keyboard.hideIfVisible { + viewModel.dispatchEvent(ChatViewModel.Event.PresentDepositOptions) + } + } + is ChatAction.OpenGroup -> { // An invite to the chat already on screen has nowhere to go. if (action.chatId != state.chatId) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt index eca2aa2a4e..96c304ba75 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt @@ -113,15 +113,23 @@ internal fun GroupGateBar( } if (unmetBalance != null) { + // Always enabled, as iOS's ConversationGatePanel is. A rule any holding satisfies, or + // one on the reserve, has no other token to buy, so it adds cash. A named mint buys + // that mint even before its name loads; the label just can't say which yet. + val mint = unmetBalance.mints.firstOrNull()?.let { Mint(it.bytes) } + val tokenToBuy = mint?.takeUnless { it == Mint.usdf } CodeButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.action_buyMoreToken, currency?.name.orEmpty()), - // Nothing to buy until the currency resolves: the label would read "Buy More " and - // the token screen has no mint to open. - enabled = currency != null, + text = when { + tokenToBuy == null -> stringResource(R.string.action_addCash) + currency == null -> stringResource(R.string.action_buyMore) + else -> stringResource(R.string.action_buyMoreToken, currency.name) + }, onClick = { - unmetBalance.mints.firstOrNull() - ?.let { onAction(ChatAction.ViewToken(Mint(it.bytes), returnAfterBuy = true)) } + onAction( + if (tokenToBuy == null) ChatAction.AddCash + else ChatAction.ViewToken(tokenToBuy, returnAfterBuy = true) + ) }, ) } else { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt index d62c4dd447..5f9143c90c 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt @@ -178,28 +178,37 @@ class GroupChromeScreenshotTest { staffOnly = false, currency = badBoys, ) - // The same state before the currency resolves: the button is disabled and the - // caption falls back to the amount alone. + // The same state before the currency resolves: "Buy More", still enabled — the + // mint is the rule's, so the button buys the right thing before it can name it. GroupGateBar( - access = GroupAccess.Blocked( - unmet = ChatRuleRequirement.MinimumBalance( - mints = emptyList(), - amount = Fiat(100.0), - ), - ), - requirement = ChatRuleRequirement.MinimumBalance( - mints = emptyList(), - amount = Fiat(100.0), - ), + access = GroupAccess.Blocked(unmet = balanceRule), + requirement = balanceRule, + onAction = {}, + staffOnly = false, + currency = null, + ) + // Any holding counts: no token to buy, so "Add Cash", and the caption states + // the amount alone. + val anyMintRule = ChatRuleRequirement.MinimumBalance( + mints = emptyList(), + amount = Fiat(100.0), + ) + GroupGateBar( + access = GroupAccess.Blocked(unmet = anyMintRule), + requirement = anyMintRule, onAction = {}, staffOnly = false, currency = null, ) // Gated on the reserve: the caption drops "of Dollars" because the amount is - // already a dollar figure, while the button still names what to go and buy. + // already a dollar figure, and the button adds cash rather than buying. + val reserveRule = ChatRuleRequirement.MinimumBalance( + mints = listOf(Mint.usdf), + amount = Fiat(100.0), + ) GroupGateBar( - access = GroupAccess.Blocked(unmet = balanceRule), - requirement = balanceRule, + access = GroupAccess.Blocked(unmet = reserveRule), + requirement = reserveRule, onAction = {}, staffOnly = false, currency = RuleCurrency(name = "Dollars", isReserve = true), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RuleTokenTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RuleTokenTest.kt new file mode 100644 index 0000000000..40deaa2244 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RuleTokenTest.kt @@ -0,0 +1,96 @@ +package com.flipcash.app.messenger.internal + +import com.getcode.opencode.model.financial.DataSource +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.LaunchpadMetadata +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.TokenResult +import com.getcode.opencode.model.financial.VmMetadata +import com.getcode.opencode.providers.TokenMetadataProvider +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +class RuleTokenTest { + + private val mint = Mint(List(32) { 1.toByte() }) + private val dummyKey = PublicKey.fromBase58("11111111111111111111111111111111") + + /** Mirrors TokenCoordinator: a network fetch of an unheld mint is not written to the cache. */ + private class FakeProvider(private val network: Token?) : TokenMetadataProvider { + val cache = MutableStateFlow>(emptyMap()) + + override suspend fun getTokenMetadata(mint: Mint): Result = + cache.value[mint]?.let { Result.success(TokenResult(it, DataSource.Memory)) } + ?: network?.let { Result.success(TokenResult(it, DataSource.Network)) } + ?: Result.failure(IllegalStateException("not found")) + + override fun observeTokenCache(): Flow> = cache + } + + @Test + fun `an unheld rule token resolves from the network`() = runTest { + val provider = FakeProvider(network = token(name = "OPOS")) + + assertEquals("OPOS", provider.observeRuleToken(mint).first().name) + } + + @Test + fun `a copy hydrated into the cache replaces the fetched one`() = runTest { + val provider = FakeProvider(network = token(name = "OPOS")) + + val names = mutableListOf() + val job = launch { provider.observeRuleToken(mint).take(2).toList().mapTo(names) { it.name } } + testScheduler.runCurrent() + provider.cache.value = mapOf(mint to token(name = "OPOS after buy")) + job.join() + + assertEquals(listOf("OPOS", "OPOS after buy"), names) + } + + @Test + fun `a failed fetch still resolves once the cache fills`() = runTest { + val provider = FakeProvider(network = null) + + val job = launch { assertEquals("OPOS", provider.observeRuleToken(mint).first().name) } + testScheduler.runCurrent() + provider.cache.value = mapOf(mint to token(name = "OPOS")) + job.join() + } + + private fun token(name: String): Token = MintMetadata( + address = mint, + decimals = 10, + name = name, + symbol = "OPOS", + createdAt = null, + description = "", + imageUrl = "", + vmMetadata = VmMetadata(vm = dummyKey, authority = dummyKey, lockDurationInDays = 21), + launchpadMetadata = LaunchpadMetadata( + currencyConfig = dummyKey, + liquidityPool = dummyKey, + seed = dummyKey, + authority = dummyKey, + mintVault = dummyKey, + coreMintVault = dummyKey, + currentCirculatingSupplyQuarks = 0, + sellFeeBps = 100, + price = Fiat(fiat = 0.01), + marketCap = Fiat(fiat = 10000.0), + ), + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 6cf70d9cb3..79f76cef51 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -15,6 +15,12 @@ sealed interface ChatAction { */ data class ViewToken(val mint: Mint, val returnAfterBuy: Boolean = false) : ChatAction + /** + * Opens the add-cash flow — the group gate's answer to a balance rule any holding satisfies, + * or one on the reserve, where there is no other token to buy. + */ + data object AddCash : ChatAction + /** * Opens the group a link card names, pushed over this chat so Back returns here. The pushed * screen gates itself; a card never joins or buys on the reader's behalf. diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt index c498e6ed8a..e1ce4aded7 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt @@ -5,6 +5,7 @@ import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.opencode.model.financial.sum import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -49,8 +50,9 @@ sealed interface GroupAccess { * * A [ChatRuleRequirement.MinimumBalance] is satisfied by the largest single balance among * the mints it names, not by their sum: the requirement is "hold $100 of this", and two - * unrelated $60 positions are not that. An empty `mints` names no token in particular, so - * any one of them can satisfy it. + * unrelated $60 positions are not that. An empty `mints` names no token in particular and + * is measured against everything held, added up, as iOS's `ConversationGate.unmetBalance` + * does with `totalBalance`. */ fun evaluate( isMember: Boolean, @@ -71,13 +73,20 @@ sealed interface GroupAccess { val unmet = listener.firstOrNull { requirement -> when (requirement) { is ChatRuleRequirement.MinimumBalance -> { - val candidates = if (requirement.mints.isEmpty()) { - byMint.values + val held = if (requirement.mints.isEmpty()) { + byMint.values.sum() } else { - requirement.mints.mapNotNull { byMint[it.bytes] } + requirement.mints.mapNotNull { byMint[it.bytes] }.maxOrNull() } - val best = candidates.maxOrNull() - best == null || best < requirement.amount + // Compared at display precision, the held side rounded half-up to cents + // (`Fiat.toDouble`), and a total rounded once, after adding: a balance the wallet shows as $5.00 meets a $5 bar even + // when its exact worth is $4.998, and $4.995 passes too. A launchpad + // holding's exact worth depends on the supply this client last saw, and one + // that lags a buy prices the new tokens a fraction below what was paid. The + // server enforces the rule against its own supply, so admitting half a cent + // too generously costs one denied join. iOS's `ConversationGate.unmetBalance` + // rounds the same way; keep the two in step. + held == null || held.toDouble() < requirement.amount.decimalValue } // `UserFlags.is_staff` is the same field the rule is written against, and the // client already has it — so staff are eligible for a staff chat and can rejoin diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt index e94eb79ddb..4d56f2bb64 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt @@ -44,6 +44,10 @@ class GroupAccessTest { private fun held(seed: Byte, symbol: String, dollars: Double) = TokenWithBalance(token = token(seed, symbol), balance = Fiat(dollars)) + /** Exact micro-dollars, for the cases that sit on a rounding edge. */ + private fun heldMicros(seed: Byte, symbol: String, micros: Long) = + TokenWithBalance(token = token(seed, symbol), balance = Fiat(quarks = micros)) + private val badBoys = mint(1) private val other = mint(2) @@ -110,6 +114,56 @@ class GroupAccessTest { assertEquals(GroupAccess.Blocked(requirement), access) } + @Test + fun `a balance that shows as the requirement clears the bar`() { + // $5 of a launchpad token valued at a supply that lags the buy comes back a fraction + // short. The wallet shows it as $5.00, so the gate must agree. + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(ChatRuleRequirement.MinimumBalance(Fiat(5.0), listOf(badBoys))), + balances = listOf(heldMicros(1, "BadBoys", 4_998_000)), + isStaff = false, + ) + + assertEquals(GroupAccess.Eligible, access) + } + + @Test + fun `half a cent short rounds up to the requirement`() { + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(ChatRuleRequirement.MinimumBalance(Fiat(5.0), listOf(badBoys))), + balances = listOf(heldMicros(1, "BadBoys", 4_995_000)), + isStaff = false, + ) + + assertEquals(GroupAccess.Eligible, access) + } + + @Test + fun `a balance that shows below the requirement is still blocked`() { + val requirement = ChatRuleRequirement.MinimumBalance(Fiat(5.0), listOf(badBoys)) + + assertEquals( + GroupAccess.Blocked(requirement), + GroupAccess.evaluate( + isMember = false, + rules = rules(requirement), + balances = listOf(heldMicros(1, "BadBoys", 4_980_000)), + isStaff = false, + ), + ) + assertEquals( + GroupAccess.Blocked(requirement), + GroupAccess.evaluate( + isMember = false, + rules = rules(requirement), + balances = listOf(heldMicros(1, "BadBoys", 4_994_999)), + isStaff = false, + ), + ) + } + @Test fun `a balance in the wrong token does not count`() { val requirement = ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys)) @@ -136,6 +190,45 @@ class GroupAccessTest { assertEquals(GroupAccess.Eligible, access) } + @Test + fun `no named mints is measured against everything held, added up`() { + val requirement = ChatRuleRequirement.MinimumBalance(Fiat(100.0), emptyList()) + + assertEquals( + GroupAccess.Eligible, + GroupAccess.evaluate( + isMember = false, + rules = rules(requirement), + balances = listOf(held(1, "BadBoys", 60.0), held(2, "Other", 60.0)), + isStaff = false, + ), + ) + assertEquals( + GroupAccess.Blocked(requirement), + GroupAccess.evaluate( + isMember = false, + rules = rules(requirement), + balances = listOf(held(1, "BadBoys", 40.0), held(2, "Other", 40.0)), + isStaff = false, + ), + ) + } + + @Test + fun `a total is rounded once, after adding`() { + val requirement = ChatRuleRequirement.MinimumBalance(Fiat(5.0), emptyList()) + + // Each $2.497 shows as $2.50, but together they are $4.994, which shows as $4.99. + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(requirement), + balances = listOf(heldMicros(1, "BadBoys", 2_497_000), heldMicros(2, "Other", 2_497_000)), + isStaff = false, + ) + + assertEquals(GroupAccess.Blocked(requirement), access) + } + @Test fun `several named mints are satisfied by the largest, not the sum`() { val requirement = diff --git a/apps/flipcash/shared/tokens/build.gradle.kts b/apps/flipcash/shared/tokens/build.gradle.kts index 471951ac69..06e80ab3f0 100644 --- a/apps/flipcash/shared/tokens/build.gradle.kts +++ b/apps/flipcash/shared/tokens/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) testImplementation(testFixtures(project(":ui:resources"))) + testImplementation(testFixtures(project(":libs:currency-math"))) testImplementation(libs.mockito.kotlin) api(project(":apps:flipcash:shared:tokens:core")) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt index d3b1f0f2e9..d2372382b2 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt @@ -20,13 +20,13 @@ import com.flipcash.app.tokens.core.TotalBalanceProvider import com.getcode.opencode.controllers.AccountController import com.getcode.opencode.controllers.TokenController import com.getcode.opencode.exchange.Exchange -import com.getcode.opencode.exchange.VerifiedFiatCalculator import com.getcode.opencode.model.ui.WindowedRange import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.DataSource import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.HistoricalMintData +import com.getcode.opencode.model.financial.LaunchpadReserveStateSnapshot import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Rate import com.getcode.opencode.model.financial.Token @@ -119,7 +119,6 @@ class TokenCoordinator @Inject constructor( private val accountController: AccountController, private val networkObserver: NetworkConnectivityListener, private val exchange: Exchange, - private val verifiedFiatCalculator: VerifiedFiatCalculator, private val dataSource: TokenDataSource, private val dispatchers: DispatcherProvider, ) : TokenMetadataProvider, SessionListener, DefaultLifecycleObserver, ReservesBalanceProvider, @@ -152,11 +151,83 @@ class TokenCoordinator @Inject constructor( private val cluster = MutableStateFlow(null) private val fetchingMints = ConcurrentHashMap.newKeySet() + /** + * What this account holds in one mint: its USD [value], and the on-chain [tokenQuarks] behind + * it where they are known. A launchpad holding's value is those quarks sold down from the + * token's supply, so a new supply re-prices them. iOS's `StoredBalance` keeps the same pair. + * + * [tokenQuarks] is null for a value restored from Room, which stores only the USD amount, and + * for one a speculative [add] or [subtract] has moved away from the last fetch. + */ + data class Holding( + val value: Fiat, + val tokenQuarks: Long? = null, + ) + data class TokenState( val tokens: Map = emptyMap(), - val balances: Map = emptyMap(), + val holdings: Map = emptyMap(), val appreciation: Map = emptyMap(), - ) + ) { + /** Each holding's USD value. */ + val balances: Map = holdings.mapValues { (_, holding) -> holding.value } + + /** + * Applies fetched accounts, each with the supply its metadata carried. + * + * That supply can be older than one the reserve stream already delivered. Neither source + * is timestamped, and the fetch is the only one for a mint the stream has not reached, so + * neither is preferred: the stream's next delivery re-prices the fetched quarks. iOS + * settles the same two writers the same way (`VerifiedProtoService.saveReserveStates`). + */ + internal fun withAccounts(updates: List): TokenState { + var next = this + for (update in updates) { + val mint = update.token.address + next = next.copy( + tokens = next.tokens + (mint to update.token), + holdings = next.holdings + (mint to Holding(update.balance, update.tokenQuarks)), + appreciation = next.appreciation + (mint to update.appreciation), + ) + } + return next + } + + /** + * Applies reserve-state updates: each token takes the new supply, and a holding whose + * [Holding.tokenQuarks] are known is re-priced from them at that supply. Cost basis is held, so + * the change lands in appreciation. + * + * A balance with no known quarks keeps its value until the next fetch. Converting the USD + * value to quarks and back at the same supply cannot re-price it, and rounds down on both + * legs: a $5 holding lost a micro-dollar on every tick. + */ + internal fun withReserveStates(updates: List): TokenState { + var next = this + for (update in updates) { + val mint = update.mint + val token = next.tokens[mint] ?: continue + val launchpad = token.launchpadMetadata ?: continue + val updatedToken = token.copy( + launchpadMetadata = launchpad.copy( + currentCirculatingSupplyQuarks = update.currentSupply, + ), + ) + next = next.copy(tokens = next.tokens + (mint to updatedToken)) + + val holding = next.holdings[mint] ?: continue + val quarks = holding.tokenQuarks ?: continue + val newBalance = runCatching { Fiat.tokenBalance(quarks, updatedToken) } + .getOrNull() ?: continue + val costBasis = holding.value - (next.appreciation[mint] ?: Fiat.Zero) + next = next.copy( + holdings = next.holdings + (mint to holding.copy(value = newBalance)), + appreciation = next.appreciation + (mint to newBalance - costBasis), + ) + } + return next + } + } private val _state = MutableStateFlow(TokenState()) private val _hydrated = MutableStateFlow(false) @@ -595,13 +666,7 @@ class TokenCoordinator @Inject constructor( private fun applyTokenUpdates(updates: List) { if (updates.isEmpty()) return - _state.update { state -> - state.copy( - tokens = state.tokens + updates.associate { it.token.address to it.token }, - balances = state.balances + updates.associate { it.token.address to it.balance }, - appreciation = state.appreciation + updates.associate { it.token.address to it.appreciation } - ) - } + _state.update { it.withAccounts(updates) } trace(tag = TAG, message = "Applied ${updates.size} token update(s), total tokens: ${_state.value.tokens.size}", type = TraceType.Process) } @@ -620,7 +685,9 @@ class TokenCoordinator @Inject constructor( } else { val newBalance = operation(currentBalance, amount) trace(tag = TAG, message = "Modified ${token.symbol} balance: ${currentBalance.formatted()} -> ${newBalance.formatted()}", type = TraceType.Process) - _state.update { it.copy(balances = it.balances + (token.address to newBalance)) } + // No quarks: they no longer describe this value, so the reserve stream must not + // re-price it from them. The fetch launched below restores both. + _state.update { it.copy(holdings = it.holdings + (token.address to Holding(newBalance))) } ensureValidTokenSelection() scope.launch { @@ -641,48 +708,8 @@ class TokenCoordinator @Inject constructor( ).collect { response -> trace(tag = TAG, message = "Received ${response.reserveStates.size} reserve state updates", type = TraceType.Process) - val state = _state.value - var updatedTokens = state.tokens - var updatedBalances = state.balances - var updatedAppreciation = state.appreciation - - for (update in response.reserveStates) { - val mint = update.reserveState.mint - val token = state.tokens[mint] ?: continue - val launchpad = token.launchpadMetadata ?: continue - - val updatedToken = token.copy( - launchpadMetadata = launchpad.copy( - currentCirculatingSupplyQuarks = update.reserveState.currentSupply - ) - ) - updatedTokens = updatedTokens + (mint to updatedToken) - - val balance = state.balances[mint] ?: continue - val exchangedValue = verifiedFiatCalculator.compute( - amount = balance, - token = updatedToken, - balance = balance, - rate = Rate.oneToOne, - trace = false, - ).getOrNull()?.localFiat?.underlyingTokenAmount - - if (exchangedValue != null) { - val newBalance = Fiat.tokenBalance( - quarks = exchangedValue.quarks, - token = updatedToken - ) - updatedBalances = updatedBalances + (mint to newBalance) - - val currentAppreciation = state.appreciation[mint] ?: Fiat.Zero - val costBasis = balance - currentAppreciation - val newAppreciation = newBalance - costBasis - updatedAppreciation = updatedAppreciation + (mint to newAppreciation) - } - } - - _state.update { - it.copy(tokens = updatedTokens, balances = updatedBalances, appreciation = updatedAppreciation) + _state.update { state -> + state.withReserveStates(response.reserveStates.map { it.reserveState }) } } } diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenStateRepricingTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenStateRepricingTest.kt new file mode 100644 index 0000000000..f469871d26 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenStateRepricingTest.kt @@ -0,0 +1,165 @@ +package com.flipcash.app.tokens + +import com.flipcash.libs.currency.math.CurveTestInitializer +import com.flipcash.libs.currency.math.Estimator +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.LaunchpadMetadata +import com.getcode.opencode.model.financial.LaunchpadReserveStateSnapshot +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.opencode.model.financial.VmMetadata +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import org.junit.BeforeClass +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +class TokenStateRepricingTest { + + companion object { + @JvmStatic + @BeforeClass + fun initCurve() { + CurveTestInitializer.initialize() + } + + private const val WHOLE_TOKEN = 10_000_000_000L // 10 decimals + private const val FIVE_DOLLARS = 5_000_000L + } + + private val mint = Mint(List(32) { 1.toByte() }) + private val dummyKey = PublicKey.fromBase58("11111111111111111111111111111111") + + @Test + fun `repeated reserve ticks at the same supply leave a bought balance unchanged`() { + val (quarks, postBuySupply) = buyFiveDollars(preBuySupply = 50_000 * WHOLE_TOKEN) + val token = launchpadToken(postBuySupply) + val bought = Fiat.tokenBalance(quarks, token) + + var state = TokenCoordinator.TokenState().withAccounts( + listOf(TokenWithBalance(token, bought, Fiat.Zero, tokenQuarks = quarks)), + ) + repeat(5) { state = state.withReserveStates(listOf(snapshot(postBuySupply))) } + + assertEquals(bought, state.balances[mint]) + assertEquals("$5.00", state.balances.getValue(mint).formatted()) + } + + @Test + fun `a balance fetched at a pre-buy supply is corrected by the next reserve tick`() { + val preBuySupply = 50_000 * WHOLE_TOKEN + val (quarks, postBuySupply) = buyFiveDollars(preBuySupply) + val staleToken = launchpadToken(preBuySupply) + val staleBalance = Fiat.tokenBalance(quarks, staleToken) + + var state = TokenCoordinator.TokenState().withAccounts( + listOf(TokenWithBalance(staleToken, staleBalance, Fiat.Zero, tokenQuarks = quarks)), + ) + state = state.withReserveStates(listOf(snapshot(postBuySupply))) + + val expected = Fiat.tokenBalance(quarks, launchpadToken(postBuySupply)) + assertEquals(expected, state.balances[mint]) + assertEquals( + postBuySupply, + state.tokens.getValue(mint).launchpadMetadata?.currentCirculatingSupplyQuarks, + ) + // Cost basis is held, so the correction shows up as appreciation. + assertEquals(expected.quarks - staleBalance.quarks, state.appreciation.getValue(mint).quarks) + } + + @Test + fun `a stream supply overwritten by a stale fetch is restored by the next delivery`() { + val preBuySupply = 50_000 * WHOLE_TOKEN + val (quarks, postBuySupply) = buyFiveDollars(preBuySupply) + val staleToken = launchpadToken(preBuySupply) + + var state = TokenCoordinator.TokenState().withAccounts( + listOf(TokenWithBalance(staleToken, Fiat.Zero, Fiat.Zero, tokenQuarks = 0)), + ) + state = state.withReserveStates(listOf(snapshot(postBuySupply))) + // The fetch read its metadata before the tick, so it carries the pre-buy supply. + state = state.withAccounts( + listOf( + TokenWithBalance( + staleToken, + Fiat.tokenBalance(quarks, staleToken), + Fiat.Zero, + tokenQuarks = quarks, + ), + ), + ) + state = state.withReserveStates(listOf(snapshot(postBuySupply))) + + assertEquals(Fiat.tokenBalance(quarks, launchpadToken(postBuySupply)), state.balances[mint]) + assertEquals( + postBuySupply, + state.tokens.getValue(mint).launchpadMetadata?.currentCirculatingSupplyQuarks, + ) + } + + @Test + fun `a balance without known quarks keeps its value through a reserve tick`() { + val token = launchpadToken(50_000 * WHOLE_TOKEN) + val restored = Fiat(quarks = FIVE_DOLLARS) + + var state = TokenCoordinator.TokenState().withAccounts( + listOf(TokenWithBalance(token, restored, Fiat.Zero, tokenQuarks = null)), + ) + state = state.withReserveStates(listOf(snapshot(60_000 * WHOLE_TOKEN))) + + assertEquals(restored, state.balances[mint]) + assertNull(state.holdings.getValue(mint).tokenQuarks) + assertEquals( + 60_000 * WHOLE_TOKEN, + state.tokens.getValue(mint).launchpadMetadata?.currentCirculatingSupplyQuarks, + ) + } + + /** Token quarks a $5 buy from [preBuySupply] yields, and the supply after it. */ + private fun buyFiveDollars(preBuySupply: Long): Pair { + val wholeTokens = Estimator.buy( + amountInQuarks = FIVE_DOLLARS, + currentSupplyInQuarks = preBuySupply, + mintDecimals = 10, + feeBps = 0, + ).getOrThrow().netTokensToReceive + val quarks = wholeTokens.movePointRight(10).toLong() + return quarks to preBuySupply + quarks + } + + private fun snapshot(supply: Long) = LaunchpadReserveStateSnapshot( + mint = mint, + currentSupply = supply, + timestamp = Instant.fromEpochMilliseconds(0), + ) + + private fun launchpadToken(supply: Long): Token = MintMetadata( + address = mint, + decimals = 10, + name = "TestCoin", + symbol = "TEST", + createdAt = null, + description = "", + imageUrl = "", + vmMetadata = VmMetadata(vm = dummyKey, authority = dummyKey, lockDurationInDays = 21), + launchpadMetadata = LaunchpadMetadata( + currencyConfig = dummyKey, + liquidityPool = dummyKey, + seed = dummyKey, + authority = dummyKey, + mintVault = dummyKey, + coreMintVault = dummyKey, + currentCirculatingSupplyQuarks = supply, + sellFeeBps = 100, + price = Fiat(fiat = 0.01), + marketCap = Fiat(fiat = 10000.0), + ), + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TokenController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TokenController.kt index 808f292d34..49cb5060f6 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TokenController.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TokenController.kt @@ -233,7 +233,12 @@ class TokenController @Inject constructor( val tokenBalance = Fiat.tokenBalance(account.balance, metadata) val appreciation = tokenBalance - Fiat(fiat = account.usdCostBasis) - return TokenWithBalance(metadata, tokenBalance, appreciation) + return TokenWithBalance( + token = metadata, + balance = tokenBalance, + appreciation = appreciation, + tokenQuarks = account.balance, + ) } // endregion diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt index 6e9cb183e4..f94f71caa0 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt @@ -24,6 +24,11 @@ data class TokenWithBalance( val balance: Fiat, val appreciation: Fiat = Fiat.MIN_VALUE, val displayName: String = token.name, + /** + * The on-chain amount [balance] was valued from, in the token's own quarks. Null when only the + * USD value is known, as for a balance restored from the local database. + */ + val tokenQuarks: Long? = null, ) { val isReserves: Boolean get() = token.address == Mint.usdf