Skip to content

Commit 442ace5

Browse files
committed
chore(flipcash/pools): handle distributions based on raw quarks
Signed-off-by: Brandon McAnsh <[email protected]>
1 parent 2eed9d3 commit 442ace5

File tree

12 files changed

+126
-47
lines changed

12 files changed

+126
-47
lines changed

apps/flipcash/features/pools/src/main/kotlin/com/flipcash/app/pools/internal/betting/PoolBettingViewModel.kt

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,9 @@ internal class PoolBettingViewModel @Inject constructor(
249249
.onResult(
250250
onSuccess = { isDistributed ->
251251
dispatchEvent(Event.OnFundsDistributed(isDistributed))
252+
},
253+
onError = {
254+
dispatchEvent(Event.OnFundsDistributed(true))
252255
}
253256
).launchIn(viewModelScope)
254257

@@ -446,20 +449,20 @@ internal class PoolBettingViewModel @Inject constructor(
446449
.mapNotNull { resolution ->
447450
val rendezvous = stateFlow.value.rendezvous
448451
if (rendezvous == null) return@mapNotNull null
449-
452+
val pool = stateFlow.value.metadata
450453
PaymentRequest.ResolvePool(
451-
pool = stateFlow.value.metadata,
454+
pool = pool,
452455
bets = stateFlow.value.bets,
453456
rendezvous = rendezvous,
454457
resolution = resolution,
455458
) {
456459
poolsCoordinator.closePool(
457-
pool = stateFlow.value.metadata,
460+
pool = pool,
458461
rendezvous = rendezvous,
459462
).fold(
460463
onSuccess = {
461464
poolsCoordinator.resolvePool(
462-
poolId = stateFlow.value.metadata.id,
465+
pool = pool,
463466
rendezvous = rendezvous,
464467
resolution = resolution,
465468
)
@@ -493,7 +496,9 @@ internal class PoolBettingViewModel @Inject constructor(
493496

494497
}
495498
is PaymentEvent.OnPaymentSuccess -> {
496-
event.acknowledge(true) {}
499+
event.acknowledge(true) {
500+
dispatchEvent(Event.OnFundsDistributed(true))
501+
}
497502
}
498503
}
499504
}

apps/flipcash/features/pools/src/main/kotlin/com/flipcash/app/pools/internal/list/components/PoolSummaryRow.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ import androidx.compose.foundation.layout.Arrangement
66
import androidx.compose.foundation.layout.Box
77
import androidx.compose.foundation.layout.Column
88
import androidx.compose.foundation.layout.Row
9+
import androidx.compose.foundation.layout.fillMaxWidth
910
import androidx.compose.foundation.layout.padding
1011
import androidx.compose.material.Icon
1112
import androidx.compose.material.Text
1213
import androidx.compose.runtime.Composable
14+
import androidx.compose.runtime.CompositionLocalProvider
1315
import androidx.compose.runtime.remember
1416
import androidx.compose.ui.Alignment
1517
import androidx.compose.ui.Modifier
18+
import androidx.compose.ui.platform.LocalContext
1619
import androidx.compose.ui.res.painterResource
1720
import androidx.compose.ui.res.stringResource
1821
import androidx.compose.ui.tooling.preview.Preview
@@ -22,7 +25,13 @@ import com.flipcash.app.core.pools.PoolResolution
2225
import com.flipcash.app.core.pools.PoolUserSummary
2326
import com.flipcash.app.theme.FlipcashDesignSystem
2427
import com.flipcash.features.pools.R
28+
import com.getcode.opencode.compose.ExchangeStub
29+
import com.getcode.opencode.compose.LocalExchange
30+
import com.getcode.opencode.model.financial.CurrencyCode
2531
import com.getcode.opencode.model.financial.Fiat
32+
import com.getcode.opencode.model.financial.Rate
33+
import com.getcode.opencode.model.financial.div
34+
import com.getcode.opencode.model.financial.times
2635
import com.getcode.opencode.model.financial.toFiat
2736
import com.getcode.solana.keys.PublicKey
2837
import com.getcode.theme.CodeTheme

apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPaymentController.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ internal class InternalPaymentController(
214214
when (error) {
215215
is PaymentError.InsufficientBalance -> presentInsufficientFundsError()
216216
is PaymentError.NoOwnerForDistribution -> presentPaymentFailedError()
217+
is PaymentError.NoPoolBalance -> presentPaymentFailedError()
217218
}
218219
}
219220

@@ -253,4 +254,5 @@ sealed interface PaymentError {
253254
data class InsufficientBalance(override val message: String? = "Insufficient balance for payment") :
254255
PaymentError, Throwable(message)
255256
data class NoOwnerForDistribution(override val message: String? = "No owner for distribution") : PaymentError, Throwable(message)
257+
data class NoPoolBalance(override val message: String? = "No pool balance") : PaymentError, Throwable(message)
256258
}

apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPoolBidDelegate.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ internal class InternalPoolBidDelegate @Inject constructor(
3636
return
3737
}
3838

39+
exchange.fetchRatesIfNeeded()
40+
3941
val localizedAmount = LocalFiat(
40-
usdc = amount.convertingTo(exchange.rateForUsd()),
42+
usdc = amount.convertingTo(exchange.rateToUsd(amount.currencyCode)!!),
4143
converted = amount,
4244
)
4345

apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/internal/InternalPoolResolveDelegate.kt

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import com.flipcash.services.models.NetworkPoolBetOutcome
1212
import com.flipcash.services.models.NetworkPoolResolution
1313
import com.flipcash.services.user.UserManager
1414
import com.getcode.ed25519.Ed25519
15+
import com.getcode.opencode.controllers.AccountController
1516
import com.getcode.opencode.controllers.TransactionController
17+
import com.getcode.opencode.exchange.Exchange
18+
import com.getcode.opencode.model.accounts.AccountCluster
19+
import com.getcode.opencode.model.accounts.AccountType
1620
import com.getcode.opencode.model.core.ID
1721
import com.getcode.opencode.model.core.RandomId
1822
import com.getcode.opencode.model.financial.Distribution
@@ -23,6 +27,8 @@ import javax.inject.Inject
2327
class InternalPoolResolveDelegate @Inject constructor(
2428
private val transactionController: TransactionController,
2529
private val userManager: UserManager,
30+
private val accountController: AccountController,
31+
private val exchange: Exchange,
2632
) : PoolResolveDelegate {
2733
override suspend fun resolvePool(
2834
pool: Pool,
@@ -46,14 +52,19 @@ class InternalPoolResolveDelegate @Inject constructor(
4652
bets = bets,
4753
)
4854

49-
val distributions = poolWithBets.buildDistributionList(resolution)
55+
val distributions = runCatching { poolWithBets.buildDistributionList(owner, resolution) }
56+
57+
distributions.exceptionOrNull()?.let {
58+
onError(it)
59+
return
60+
}
5061

5162
val poolAccount = userManager.poolAccountAt(pool.derivationIndex)
5263

5364
transactionController.distributeFunds(
5465
owner = owner,
5566
from = poolAccount.cluster,
56-
distributions = distributions
67+
distributions = distributions.getOrNull().orEmpty()
5768
).map {
5869
it.id.bytes
5970
}.onSuccess {
@@ -63,25 +74,59 @@ class InternalPoolResolveDelegate @Inject constructor(
6374
}
6475
}
6576

66-
private fun PoolWithBets.buildDistributionList(resolution: PoolResolution.DecisionMade): List<Distribution> {
77+
private suspend fun PoolWithBets.buildDistributionList(
78+
owner: AccountCluster,
79+
resolution: PoolResolution.DecisionMade
80+
): List<Distribution> {
6781
val paidBets = bets.filter { it.hasPaidForBet }
68-
6982
// 1. if the decision was to refund, then all paid bets are returned
7083
if (resolution is PoolResolution.Refund) {
84+
val rate = exchange.rateToUsd(pool.buyIn.currencyCode)
85+
?: throw IllegalArgumentException("No rate found for ${pool.buyIn.currencyCode}")
86+
val usdc = pool.buyIn.convertingTo(rate)
7187
return paidBets.map {
7288
Distribution(
7389
destination = it.payoutDestination,
74-
amount = pool.buyIn,
90+
amount = usdc,
7591
)
7692
}
7793
}
7894

7995
val matchingBets = paidBets.filter { it.selectedOutcome.matchesResolution(resolution) }
96+
97+
val poolBalance = accountController.getAccount(
98+
accountOwner = owner,
99+
requestingOwner = owner,
100+
predicate = { account ->
101+
val indexMatch = account.index.toLong() == pool.derivationIndex
102+
val addressMatch = account.address == pool.fundingDestination
103+
val isPool = account.accountType == AccountType.Pool
104+
indexMatch && addressMatch && isPool
105+
},
106+
).getOrNull()?.balance
107+
108+
if (poolBalance == null) {
109+
throw PaymentError.NoPoolBalance()
110+
}
111+
112+
val winnerCount = matchingBets.count()
113+
114+
// Calculate base amount per winner and remainder
115+
val baseAmountPerWinner = poolBalance.quarks / winnerCount
116+
val remainderQuarks = poolBalance.quarks % winnerCount
117+
80118
// 2. otherwise, pay out all winning (matching bets)
81-
return matchingBets.map {
119+
// unequal remainder is added to the 'remainderQuarks' winners
120+
return matchingBets.mapIndexed { index, bet ->
121+
val amount = if (index < remainderQuarks) {
122+
// Add 1 extra quark to the first 'remainderQuarks' winners
123+
baseAmountPerWinner + 1
124+
} else {
125+
baseAmountPerWinner
126+
}
82127
Distribution(
83-
destination = it.payoutDestination,
84-
amount = winningAmountForResolution(resolution)
128+
destination = bet.payoutDestination,
129+
amount = Fiat(amount)
85130
)
86131
}
87132
}

apps/flipcash/shared/pools/src/main/kotlin/com/flipcash/app/pools/PoolsCoordinator.kt

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -134,18 +134,17 @@ class PoolsCoordinator @Inject constructor(
134134
suspend fun isPoolDistributed(pool: Pool): Result<Boolean> {
135135
val owner = userManager.accountCluster
136136
?: return Result.failure(Throwable("No account cluster"))
137-
return accountController.getAccounts(
137+
return accountController.getAccount(
138138
accountOwner = owner,
139139
requestingOwner = owner,
140-
).map {
141-
val info = it.accounts.values.firstOrNull { account ->
140+
predicate = { account ->
142141
val indexMatch = account.index.toLong() == pool.derivationIndex
143142
val addressMatch = account.address == pool.fundingDestination
144143
val isPool = account.accountType == AccountType.Pool
145144
indexMatch && addressMatch && isPool
146-
} ?: return Result.failure(Throwable("Account for pool not found"))
147-
148-
info.balance == Fiat.Zero
145+
}
146+
).map {
147+
it.balance == Fiat.Zero
149148
}.onFailure {
150149
return Result.failure(it)
151150
}
@@ -159,35 +158,32 @@ class PoolsCoordinator @Inject constructor(
159158
pool: Pool,
160159
rendezvous: KeyPair,
161160
): Result<Instant> {
162-
val metadata = domainToNetworkMapper.map(pool)
163-
return controller.closePool(metadata, rendezvous)
164-
.onSuccess { closedAt ->
161+
// ensure we are working with an up-to-date instance of a pool
162+
return controller.getPool(pool.id)
163+
.fold(
164+
onSuccess = {
165+
controller.closePool(it.metadata, rendezvous)
166+
},
167+
onFailure = {
168+
Result.failure(it)
169+
}
170+
).onSuccess { closedAt ->
165171
dataSource.closePool(pool.id, closedAt)
166172
}
167173
}
168174

169175
suspend fun resolvePool(
170-
poolId: ID,
176+
pool: Pool,
171177
resolution: PoolResolution.DecisionMade,
172178
rendezvous: KeyPair
173179
): Result<Unit> {
174-
return controller.getPool(poolId)
175-
.fold(
176-
onSuccess = {
177-
controller.resolvePool(
178-
pool = it.metadata,
179-
rendezvous = rendezvous,
180-
resolution = PoolResolutionConverter.toPoolResolution(resolution as PoolResolution),
181-
)
182-
},
183-
onFailure = { Result.failure(it) }
184-
).fold(
185-
onSuccess = {
186-
dataSource.resolvePool(poolId, resolution)
187-
Result.success(Unit)
188-
},
189-
onFailure = { Result.failure(it) }
190-
)
180+
return controller.resolvePool(
181+
pool = domainToNetworkMapper.map(pool),
182+
rendezvous = rendezvous,
183+
resolution = PoolResolutionConverter.toPoolResolution(resolution as PoolResolution),
184+
).onSuccess {
185+
dataSource.resolvePool(pool.id, resolution)
186+
}
191187
}
192188

193189
suspend fun placeBet(

services/opencode-compose/src/main/kotlin/com/getcode/opencode/compose/Exchange.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ private class ExchangeNull(override val staleThreshold: Duration = 1.days) : Exc
6565
return null
6666
}
6767

68-
override suspend fun fetchRatesIfNeeded() = Unit
68+
override suspend fun fetchRatesIfNeeded(force: Boolean) = Unit
6969

7070
override fun rateFor(currencyCode: CurrencyCode): Rate? {
7171
return null

services/opencode-compose/src/main/kotlin/com/getcode/opencode/compose/ExchangeStub.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ class ExchangeStub(
7676
).let { if (it == 0) null else it }
7777
}
7878

79-
override suspend fun fetchRatesIfNeeded() = Unit
79+
override suspend fun fetchRatesIfNeeded(force: Boolean) = Unit
8080

8181
override fun rateFor(currencyCode: CurrencyCode): Rate? {
8282
return providedRates[currencyCode]

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.getcode.crypt.MnemonicPhrase
44
import com.getcode.ed25519.Ed25519
55
import com.getcode.opencode.internal.network.api.intents.IntentCreateAccount
66
import com.getcode.opencode.model.accounts.AccountCluster
7+
import com.getcode.opencode.model.accounts.AccountInfo
78
import com.getcode.opencode.model.accounts.AccountResponse
89
import com.getcode.opencode.model.accounts.PoolAccount
910
import com.getcode.opencode.model.core.ID
@@ -50,4 +51,23 @@ class AccountController @Inject constructor(
5051
requestingOwner = requestingOwner.authority.keyPair
5152
)
5253
}
54+
55+
suspend fun getAccount(
56+
accountOwner: AccountCluster,
57+
requestingOwner: AccountCluster,
58+
predicate: (AccountInfo) -> Boolean,
59+
): Result<AccountInfo> {
60+
return getAccounts(
61+
accountOwner = accountOwner,
62+
requestingOwner = requestingOwner,
63+
).map {
64+
it.accounts.values.find(predicate)
65+
}.fold(
66+
onSuccess = {
67+
if (it != null) Result.success(it)
68+
else Result.failure(NoSuchElementException())
69+
},
70+
onFailure = { Result.failure(it) }
71+
)
72+
}
5373
}

services/opencode/src/main/kotlin/com/getcode/opencode/exchange/Exchange.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ interface Exchange {
2525
fun getFlagByCurrency(currencyCode: String?): Int?
2626
fun getFlag(countryCode: String): Int?
2727

28-
suspend fun fetchRatesIfNeeded()
28+
suspend fun fetchRatesIfNeeded(force: Boolean = false)
2929

3030
fun rateFor(currencyCode: CurrencyCode): Rate?
3131

0 commit comments

Comments
 (0)