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 @@ -14,7 +14,7 @@ import com.flipcash.shared.transactionhistory.convertOf
import com.getcode.opencode.mapper.Mapper
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.Token
import com.getcode.solana.keys.Mint
import com.getcode.opencode.model.financial.formattedQuantity
import com.getcode.util.resources.ResourceHelper
import com.getcode.utils.base58
import com.getcode.utils.hexEncodedString
Expand Down Expand Up @@ -163,20 +163,21 @@ private fun statusOf(state: MessageState, swapState: SwapState?): TransactionSta
}

/**
* How many tokens the entry moved, formatted to the mint's own precision.
* How many tokens the entry moved.
*
* The reserve is one-to-one with its USD value, so it needs no curve. Every other mint is priced by
* the bonding curve, and [Fiat.estimatedTokenAmountIn] prices it against the mint's *current*
* supply — so this is the quantity that value is worth now, not what it bought at the time. Stating
* it is still better than leaving the row blank, since the value is the same value the header shows.
* This is the quantity the feed recorded, not one re-derived from the mint's current supply. The
* server sends it on every entry — `CryptoPaymentAmount.quarks`, which
* [com.getcode.opencode.model.financial.LocalFiat.underlyingTokenAmount] carries verbatim: dollars
* for the reserve, that mint's own quarks for everything else. Pricing the value through the
* bonding curve instead would answer a different question — what it would buy today — and would
* drift further from what moved the longer ago the entry was. iOS reads the same field for the
* same row (`ExchangedFiat.onChainAmount`), so the two screens state one number.
*
* Without the mint there is no scale to write the quarks at, and a quantity at the wrong scale is
* worse than none — so an unresolved mint leaves the row out until it lands.
*/
private fun tokenAmountOf(underlying: Fiat?, token: Token?): String? {
underlying ?: return null
token ?: return null
val quantity = if (token.address == Mint.usdf) {
underlying
} else {
Fiat.tokenBalance(underlying.quarks, token)
}
return quantity.estimatedTokenAmountIn(token)
return token.formattedQuantity(underlying.quarks)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.HolderMetrics
import com.getcode.opencode.model.financial.LocalFiat
import com.getcode.opencode.model.financial.MintMetadata
import com.getcode.opencode.model.financial.Rate
import com.getcode.opencode.model.financial.Token
import com.getcode.opencode.model.financial.VmMetadata
import com.getcode.solana.keys.Mint
Expand Down Expand Up @@ -48,17 +49,22 @@ class TransactionDetailsMapperTest {

private val vault = PublicKey.fromBase58("11111111111111111111111111111111")

private fun mint(byte: Byte) = Mint(List(32) { byte })

private val twentyDollars = LocalFiat(
usdf = Fiat(20.0, CurrencyCode.USD),
nativeAmount = Fiat(20.0, CurrencyCode.USD),
)

private fun feedMessage(
metadata: MessageMetadata?,
text: String = "Sent",
state: MessageState = MessageState.COMPLETED,
amount: LocalFiat = twentyDollars,
): ActivityFeedMessage = ActivityFeedMessage(
id = listOf(0x01, 0x02, 0x03).map { it.toByte() },
text = text,
amount = LocalFiat(
usdf = Fiat(20.0, CurrencyCode.USD),
nativeAmount = Fiat(20.0, CurrencyCode.USD),
),
amount = amount,
timestamp = Instant.fromEpochSeconds(1700000000L),
state = state,
metadata = metadata,
Expand All @@ -70,17 +76,23 @@ class TransactionDetailsMapperTest {
state: MessageState = MessageState.COMPLETED,
token: Token? = null,
toToken: Token? = null,
amount: LocalFiat = twentyDollars,
): TransactionDetails = mapper.map(
ActivityFeedMessageWithToken(
feedMessage(metadata, text, state),
feedMessage(metadata, text, state, amount),
token = token,
toToken = toToken,
) to cached
)

private fun token(address: Mint, name: String, symbol: String): Token = MintMetadata(
private fun token(
address: Mint,
name: String,
symbol: String,
decimals: Int = 6,
): Token = MintMetadata(
address = address,
decimals = 6,
decimals = decimals,
name = name,
symbol = symbol,
createdAt = null,
Expand Down Expand Up @@ -222,4 +234,60 @@ class TransactionDetailsMapperTest {
// To/From row has nothing to render until one does.
assertNull(map(MessageMetadata.WithdrewCrypto()).account)
}

// region tokens

@Test
fun `the tokens row states the quantity the feed recorded, at the mint's own scale`() {
val jeffy = token(mint(1), "Jeffy", "JEFFY", decimals = 10)
// 1,204.905 JEFFY as the server sends it: ten-decimal quarks, verbatim.
val amount = LocalFiat(
underlyingTokenAmount = Fiat(quarks = 12_049_050_000_000L),
nativeAmount = Fiat(20.0, CurrencyCode.USD),
rate = Rate(fx = 1.0, currency = CurrencyCode.USD),
mint = jeffy.address,
)

val details = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId), token = jeffy, amount = amount)

assertEquals("1,204.905", details.tokenAmount)
}

@Test
fun `a mint with no launchpad metadata still states its quantity`() {
// The old reading priced the value against `launchpadMetadata.currentCirculatingSupplyQuarks`,
// which falls back to a supply of zero when the metadata hasn't resolved. Reading the
// recorded quantity has nothing to fall back from.
val jeffy = token(mint(1), "Jeffy", "JEFFY", decimals = 10)
val amount = LocalFiat(
underlyingTokenAmount = Fiat(quarks = 5_000_000_000L),
nativeAmount = Fiat(20.0, CurrencyCode.USD),
rate = Rate(fx = 1.0, currency = CurrencyCode.USD),
mint = jeffy.address,
)

assertNull(jeffy.launchpadMetadata)
assertEquals(
"0.5",
map(MessageMetadata.BoughtToken, token = jeffy, amount = amount).tokenAmount,
)
}

@Test
fun `the reserve's quantity is its dollars`() {
// USDF is one-to-one with its USD value and shares Fiat's six decimals, so the $20 the
// header shows is twenty tokens.
val details = map(MessageMetadata.DepositedCrypto, token = token(Mint.usdf, "Dollars", "USDF"))

assertEquals("20", details.tokenAmount)
}

@Test
fun `an unresolved mint leaves the tokens row out`() {
// Without the mint's decimals there is no scale to state the quarks at, and a wrong
// quantity is worse than none.
assertNull(map(MessageMetadata.DepositedCrypto, token = null).tokenAmount)
}

// endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,15 @@ internal object TransactionDetailsSamples {
* test's image loader answers from `src/test/resources/tokens/` — the tokens' own icons,
* through the same [com.flipcash.app.core.ui.TokenIcon] path the app uses.
*/
private fun token(name: String, symbol: String, address: Mint, image: String): Token = MintMetadata(
private fun token(
name: String,
symbol: String,
address: Mint,
image: String,
decimals: Int,
): Token = MintMetadata(
address = address,
decimals = 6,
decimals = decimals,
name = name,
symbol = symbol,
createdAt = At,
Expand All @@ -47,10 +53,11 @@ internal object TransactionDetailsSamples {
holderMetrics = HolderMetrics.None,
)

val Jeffy: Token = token("Jeffy", "JEFFY", mint(1), "jeffy.png")
/** Ten decimals, like every launchpad mint — which is what makes the Tokens row trim its zeros. */
val Jeffy: Token = token("Jeffy", "JEFFY", mint(1), "jeffy.png", decimals = 10)

/** The reserve — the real mint, since the screen keys off it the way the rest of the app does. */
val Dollars: Token = token("Dollars", "USDF", Mint.usdf, "dollars.webp")
val Dollars: Token = token("Dollars", "USDF", Mint.usdf, "dollars.webp", decimals = 6)

/** No profile picture, so the avatar draws her initials — the app's real no-photo state. */
val Sally = UserProfile.Empty.copy(displayName = "Sally The Streamer")
Expand Down Expand Up @@ -114,7 +121,7 @@ internal object TransactionDetailsSamples {
avatar = TransactionAvatar.TokenIcon(Dollars),
amount = usd(1.00),
token = Dollars,
tokenAmount = "1.000000",
tokenAmount = "1",
subtitle = "In Person",
prefix = "+",
)
Expand Down Expand Up @@ -155,7 +162,7 @@ internal object TransactionDetailsSamples {
avatar = TransactionAvatar.TokenIcon(Dollars),
amount = usd(120.00),
token = Dollars,
tokenAmount = "120.000000",
tokenAmount = "120",
subtitle = null,
prefix = "-",
).copy(account = TransactionAccount(Account, TransactionAccount.Direction.To))
Expand All @@ -165,7 +172,7 @@ internal object TransactionDetailsSamples {
avatar = TransactionAvatar.TokenIcon(Dollars),
amount = usd(250.00),
token = Dollars,
tokenAmount = "250.000000",
tokenAmount = "250",
subtitle = null,
prefix = "+",
).copy(account = TransactionAccount(Account, TransactionAccount.Direction.From))
Expand All @@ -175,7 +182,7 @@ internal object TransactionDetailsSamples {
avatar = TransactionAvatar.SwapTokens(from = Dollars, to = Jeffy),
amount = usd(40.00),
token = Dollars,
tokenAmount = "40.000000",
tokenAmount = "40",
subtitle = "Dollars → Jeffy",
prefix = "-",
).copy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import com.getcode.solana.keys.Mint
import com.getcode.solana.keys.PublicKey
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Instant

Expand Down Expand Up @@ -179,6 +183,28 @@ data class MintMetadata(
companion object
}

/**
* [quarks] of this mint, written out as a quantity of tokens.
*
* The unit a quark is a fraction of is the mint's own, so the scale has to come from
* [MintMetadata.decimals] rather than a constant: the reserve has six, every launchpad mint has ten
* (`DefaultMintQuarksPerUnit`). Ten of them puts a full supply at 2.1e17 quarks, past the range
* where a `Double` still counts integers exactly, so the shift is done in [BigDecimal] — roughly
* 900,000 tokens is where dividing by a power of ten in floating point starts losing the low
* digits.
*
* Trailing zeros are dropped rather than padded out to [MintMetadata.decimals], since
* "1,204.9050000000" states no more than "1,204.905".
*/
fun Token.formattedQuantity(quarks: Long): String {
val formatter = DecimalFormat.getInstance(Locale.US).apply {
maximumFractionDigits = decimals
minimumFractionDigits = 0
roundingMode = RoundingMode.HALF_UP
}
return formatter.format(BigDecimal.valueOf(quarks).movePointLeft(decimals))
}

/**
* Represents metadata associated with a VM.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.getcode.opencode.model.financial

import com.getcode.opencode.model.ui.WindowedRange
import com.getcode.solana.keys.Mint
import com.getcode.opencode.tests.generateRandomPublicKeyForTest
import org.junit.Test
import kotlin.test.assertEquals
Expand Down Expand Up @@ -84,6 +85,55 @@ class MintMetadataTest {

// endregion

// region formattedQuantity

private fun mint(decimals: Int): Token {
val key = generateRandomPublicKeyForTest()
return MintMetadata(
address = Mint(key.bytes),
decimals = decimals,
name = "Jeffy",
symbol = "JEFFY",
createdAt = null,
description = "",
imageUrl = "",
vmMetadata = VmMetadata(vm = key, authority = key, lockDurationInDays = 21),
launchpadMetadata = null,
billCustomizations = null,
socialLinks = emptyList(),
holderMetrics = HolderMetrics.None,
)
}

@Test
fun `quarks are shifted by the mint's own decimals, not a constant`() {
// The same number of quarks is a thousandfold difference between a launchpad mint and the
// reserve, which is the whole reason the scale comes off the mint.
assertEquals("1,204.905", mint(decimals = 10).formattedQuantity(12_049_050_000_000L))
assertEquals("12,049,050", mint(decimals = 6).formattedQuantity(12_049_050_000_000L))
}

@Test
fun `trailing zeros are dropped`() {
assertEquals("0.5", mint(decimals = 10).formattedQuantity(5_000_000_000L))
assertEquals("20", mint(decimals = 6).formattedQuantity(20_000_000L))
assertEquals("0", mint(decimals = 10).formattedQuantity(0L))
}

@Test
fun `a quantity past a Double's exact range keeps its low digits`() {
// 1.2e17 quarks is well past 2^53, where a Double stops counting integers one at a time —
// shifting this in floating point loses the tail. A launchpad mint holds ten decimals and
// supplies run to 21 million tokens, so this is inside the range the screen has to state,
// not a contrived edge.
assertEquals(
"12,345,678.9012345678",
mint(decimals = 10).formattedQuantity(123_456_789_012_345_678L),
)
}

// endregion

// region LaunchpadMetadata

@Test
Expand Down
Loading