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 @@ -127,8 +127,9 @@ internal class CurrencyCreatorViewModel @Inject constructor(
val fundingMint: Mint? = null,
val attestations: ModerationAttestations = ModerationAttestations(),
) {
/** The name passes the contract's [CurrencyName] rule, so the checks and the launch will accept it. */
val hasName: Boolean
get() = nameFieldState.text.isNotBlank()
get() = CurrencyName.isValid(nameFieldState.text)

val descriptionLength: Int
get() = descriptionFieldState.text.trimmedLength()
Expand Down Expand Up @@ -318,6 +319,8 @@ internal class CurrencyCreatorViewModel @Inject constructor(

eventFlow
.filterIsInstance<Event.CheckName>()
// The keyboard Done action reaches here without passing the button's enabled gate.
.filter { stateFlow.value.hasName }
.map { stateFlow.value.nameFieldState.text.toString() }
.onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) }
.map { moderationController.moderateText(it.trim()) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.flipcash.app.currencycreator.internal

/**
* The currency name rule, from `CheckAvailabilityRequest.name` and `LaunchRequest.name` in the
* pinned OCP contract: `min_len 1`, `max_len 32` and `pattern ^[!-~]([ -~]*[!-~])?$`, which is
* printable ASCII with no leading or trailing space.
*
* Checked on the trimmed text, since trimmed text is what both requests send. Every allowed
* character is ASCII, so [String.length] counts it the way the server does.
*/
internal object CurrencyName {
const val MAX_LENGTH = 32

private val pattern = Regex("^[!-~]([ -~]*[!-~])?$")

fun normalize(raw: CharSequence): String = raw.toString().trim()

fun isValid(raw: CharSequence): Boolean {
val name = normalize(raw)
return name.length in 1..MAX_LENGTH && pattern.matches(name)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.flipcash.app.currencycreator.internal

import androidx.compose.foundation.text.input.TextFieldState
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue

/** The client half of `CheckAvailabilityRequest.name`: `max_len 32`, `^[!-~]([ -~]*[!-~])?$`. */
class CurrencyNameTest {

@Test
fun `a name at the 32 character limit passes and one over is blocked`() {
assertTrue(CurrencyName.isValid("a".repeat(32)))
assertFalse(CurrencyName.isValid("a".repeat(33)))
}

@Test
fun `the limit is measured on the trimmed name`() {
assertTrue(CurrencyName.isValid(" " + "a".repeat(32) + " "))
}

@Test
fun `an empty or blank name is blocked`() {
assertFalse(CurrencyName.isValid(""))
assertFalse(CurrencyName.isValid(" "))
}

@Test
fun `printable ASCII with inner spaces passes`() {
assertTrue(CurrencyName.isValid("Bad Boys 2!"))
}

@Test
fun `non ASCII, emoji and control characters are blocked`() {
assertFalse(CurrencyName.isValid("Caf\u00e9"))
assertFalse(CurrencyName.isValid("\uD83D\uDE00"))
assertFalse(CurrencyName.isValid("Bad\tBoys"))
assertFalse(CurrencyName.isValid("Bad\nBoys"))
}

@Test
fun `the state arms Next only for a valid name`() {
fun hasName(text: String) = CurrencyCreatorViewModel.State(nameFieldState = TextFieldState(text)).hasName

assertTrue(hasName("a".repeat(32)))
assertFalse(hasName("a".repeat(33)))
assertFalse(hasName("Caf\u00e9"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.flipcash.app.analytics.FlipcashAnalytics
import com.flipcash.libs.coroutines.DispatcherProvider
import com.flipcash.services.models.chat.ChatId
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.ChatTitle
import com.getcode.util.resources.ResourceHelper
import com.getcode.view.BaseViewModel
import com.getcode.view.LoadingSuccessState
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import com.flipcash.app.core.ui.DisplayTextInput
import com.flipcash.features.messenger.R
import com.flipcash.shared.chat.ChatTitle
import com.getcode.theme.CodeTheme
import com.getcode.ui.components.AppBarWithTitle
import com.getcode.ui.theme.CodeButton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.flipcash.app.messenger.internal.screens.profile.edit
import com.flipcash.services.models.chat.BlobId
import com.flipcash.services.models.chat.DescriptionEdit
import com.flipcash.services.models.chat.EditChatParameters
import com.flipcash.shared.chat.ChatTitle

/**
* The edits the edit screens can make, each named so the "send only what changed" rule is a thing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.flipcash.shared.amountentry.AmountEntryDelegate
import com.flipcash.shared.amountentry.AmountEntryLabel
import com.flipcash.shared.amountentry.AmountEntryStyle
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.ChatTitle
import com.flipcash.shared.chat.GroupAccess
import com.getcode.manager.BottomBarManager
import com.getcode.opencode.exchange.Exchange
Expand Down Expand Up @@ -129,8 +130,9 @@ internal class CreateGroupViewModel @AssistedInject constructor(
val title: String
get() = titleFieldState.text.toString().trim()

/** `StartChat`'s `min_len 1, max_len 64`, counted as [ChatTitle] counts it (trimmed code points). */
val hasTitle: Boolean
get() = title.isNotEmpty()
get() = ChatTitle.isValid(titleFieldState.text)

/**
* The selected token's metadata, which is where the row's name and image come from. Null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,23 @@ class CreateGroupStateTest {
assertFalse(state(title = " ").canCreate)
}

@Test
fun `a title at the 64 code point limit can be created and one over cannot`() {
val balances = listOf(held(1, "BadBoys", 100.0))

assertTrue(state(title = "a".repeat(64), balances = balances).canCreate)
assertFalse(state(title = "a".repeat(65), balances = balances).canCreate)
}

@Test
fun `the title limit counts code points, not UTF-16 units`() {
val balances = listOf(held(1, "BadBoys", 100.0))

// 64 emoji are 128 UTF-16 units but 64 code points.
assertTrue(state(title = "\uD83D\uDE00".repeat(64), balances = balances).canCreate)
assertFalse(state(title = "\uD83D\uDE00".repeat(65), balances = balances).canCreate)
}

@Test
fun `a creator who holds the requirement can create`() {
val state = state(balances = listOf(held(1, "BadBoys", 100.0)))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.flipcash.app.userprofile.internal.name

internal const val DISPLAY_NAME_MAX_LENGTH = 64

/**
* `SetDisplayNameRequest.display_name`: `min_len 1`, `max_len 64`, in the pinned Flipcash contract.
*
* Applies to the text as submitted. Sources other than onboarding send the field text as typed,
* so it is measured as typed; a blank name is refused as well, as before. Length is counted in
* code points, as protovalidate counts it, not in UTF-16 units, which would refuse emoji names the
* server accepts.
*/
internal fun isWithinDisplayNameLimit(typed: String): Boolean =
typed.isNotBlank() && typed.codePointCount(0, typed.length) <= DISPLAY_NAME_MAX_LENGTH
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,13 @@ class NameEntryViewModel @Inject constructor(

/**
* Whether the current text may be submitted. Onboarding applies the stricter
* [isValidOnboardingDisplayName] rule; other sources only need a non-blank name.
* [isValidOnboardingDisplayName] rule; other sources need a non-blank name within the
* contract's [DISPLAY_NAME_MAX_LENGTH].
*/
fun isSubmittable(source: DisplayNameSource): Boolean = when (source) {
DisplayNameSource.Onboarding -> isValidOnboardingDisplayName(nameFieldState.text.toString())
DisplayNameSource.MyAccount,
DisplayNameSource.TipCardSetup -> hasName
DisplayNameSource.TipCardSetup -> isWithinDisplayNameLimit(nameFieldState.text.toString())
}

/** Node 9553:113166 — one character's difference is enough to arm the confirm button. */
Expand Down Expand Up @@ -124,9 +125,8 @@ class NameEntryViewModel @Inject constructor(
.filterIsInstance<Event.ConfirmNameChange>()
.onEach { event ->
// The keyboard Done action reaches here without passing the button's enabled
// gate, so an invalid onboarding name is dropped the same way the disabled
// button drops it.
if (event.source == DisplayNameSource.Onboarding && !stateFlow.value.isSubmittable(event.source)) {
// gate, so an invalid name is dropped the same way the disabled button drops it.
if (!stateFlow.value.isSubmittable(event.source)) {
return@onEach
}
if (stateFlow.value.savedName.isBlank()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flipcash.app.userprofile.internal.name

import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import com.flipcash.app.core.DisplayNameSource
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
Expand Down Expand Up @@ -99,4 +100,21 @@ class NameEntryStateTest {
assertEquals("Brandon McAnshx", refreshed.nameFieldState.text.toString())
assertTrue(refreshed.isChanged)
}

@Test
fun `outside onboarding a name at the 64 limit can be submitted and one over cannot`() {
for (source in listOf(DisplayNameSource.MyAccount, DisplayNameSource.TipCardSetup)) {
assertTrue(stateWith(saved = "", typed = "a".repeat(64)).isSubmittable(source))
assertFalse(stateWith(saved = "", typed = "a".repeat(65)).isSubmittable(source))
}
}

@Test
fun `outside onboarding the limit counts code points and a blank name is refused`() {
val source = DisplayNameSource.MyAccount

assertTrue(stateWith(saved = "", typed = "\uD83D\uDE00".repeat(64)).isSubmittable(source))
assertFalse(stateWith(saved = "", typed = "\uD83D\uDE00".repeat(65)).isSubmittable(source))
assertFalse(stateWith(saved = "", typed = " ").isSubmittable(source))
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.flipcash.app.messenger.internal.screens.profile.edit
package com.flipcash.shared.chat

/**
* `EditChatRequest.Title.value` — `min_len 1, max_len 64`, checked here before the call.
Expand All @@ -12,7 +12,7 @@ package com.flipcash.app.messenger.internal.screens.profile.edit
* here and 40 at the server. Using [String.length] would refuse titles the contract accepts, and
* would do it only for non-BMP characters — the case least likely to be noticed.
*/
internal object ChatTitle {
object ChatTitle {
const val MIN_LENGTH = 1
const val MAX_LENGTH = 64

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.flipcash.app.messenger.internal.screens.profile.edit
package com.flipcash.shared.chat

import kotlin.test.Test
import kotlin.test.assertEquals
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,11 @@ private fun DeepLink.handleCashLink(): DeeplinkType.CashLink? {

private fun DeepLink.handleTokenLink(): DeeplinkType.TokenInfo? {
val uri = data.toUri()
val mint = uri.pathSegments.getOrNull(1) ?: return null
return DeeplinkType.TokenInfo(Mint(mint))
val segment = uri.pathSegments.getOrNull(1) ?: return null
// A link with a truncated or non-base58 address would otherwise reach the token fetch as a
// short mint and fail the server's 32-byte rule.
val mint = runCatching { Mint(segment) }.getOrNull()?.takeIf { it.isValid } ?: return null
return DeeplinkType.TokenInfo(mint)
}

// https://app.flipcash.com/tip/chat/{url encoded chatId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ class AppRouterTest {
assertEquals(Mint(mintAddress), type.mint)
}

@Test
fun `classify ignores a token deeplink whose address is shorter than 32 bytes`() {
assertNull(router.classify(DeepLink("https://app.flipcash.com/token/So1111")))
}

@Test
fun `classify ignores a token deeplink whose address is not base58`() {
assertNull(router.classify(DeepLink("https://app.flipcash.com/token/0OIl-not-base58")))
}

// endregion

// region classify — EmailVerification
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@ class TokenCoordinator @Inject constructor(
* eventually replaced with fresh data.
*/
override suspend fun getTokenMetadata(mint: Mint): Result<TokenResult> {
if (!mint.isValid) return invalidMint("getTokenMetadata", mint)

// 1. In-memory cache
_state.value.tokens[mint]?.let { cached ->
return Result.success(TokenResult(cached, DataSource.Memory))
Expand Down Expand Up @@ -524,9 +526,20 @@ class TokenCoordinator @Inject constructor(
currencyCode: CurrencyCode,
windowedRange: WindowedRange,
): Result<List<HistoricalMintData>> {
if (!mint.isValid) return invalidMint("getHistoricalMarketCapData", mint)
return tokenController.getHistoricalMarketCapData(mint, currencyCode, windowedRange)
}

/**
* Fails a lookup for a mint that is not 32 bytes before it reaches the network, where the
* request check would reject it as a validation error with no trace of who asked.
*/
private fun invalidMint(caller: String, mint: Mint): Result<Nothing> {
val message = "$caller called with a ${mint.bytes.size}-byte mint: '${mint.base58()}'"
trace(tag = TAG, message = message, type = TraceType.Error)
return Result.failure(IllegalArgumentException(message))
}

suspend fun reset() {
val previousTokenCount = _state.value.tokens.size
_state.value = TokenState()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.getcode.solana.keys

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class MintTest {
Expand Down Expand Up @@ -38,4 +39,16 @@ class MintTest {
fun `Mint usdf has correct base58 address`() {
assertEquals("5AMAA9JV9H97YYVxx8F6FsCMmTwXSuTTQneiup4RYAUQ", Mint.usdf.base58())
}

@Test
fun `a 32-byte mint is valid`() {
assertTrue(Mint(List(32) { 1 }).isValid)
assertTrue(Mint.usdf.isValid)
}

@Test
fun `a mint of 31 bytes or an empty one is not valid`() {
assertFalse(Mint(List(31) { 1 }).isValid)
assertFalse(Mint(emptyList()).isValid)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import kotlinx.serialization.Serializable
class Mint(bytes: List<Byte>): PublicKey(bytes) {
constructor(base58: String) : this(Base58.decode(base58).toList())

/**
* Whether this is a full 32-byte address. Anything shorter fails the server's
* `addresses`/`address` length rule (`at least 32 bytes`) if sent in a request.
*/
val isValid: Boolean
get() = bytes.size == LENGTH_32

companion object {
@JvmField
val CREATOR: Any = parcelableCreator({ arrayOfNulls<Mint>(it) }) { parcel -> Mint(parcel.readString().orEmpty()) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.getcode.opencode.controllers.AccountController
import com.getcode.opencode.events.Events
import com.getcode.opencode.managers.MnemonicManager
import com.getcode.opencode.model.accounts.AccountCluster
import com.getcode.opencode.model.core.NoId
import com.getcode.opencode.model.financial.MintMetadata
import com.getcode.opencode.model.financial.usdf
import com.getcode.opencode.utils.generate
Expand Down Expand Up @@ -145,4 +146,24 @@ class UserManagerTest {
assertNull(um.entropy)
assertNull(um.userFlags)
}

@Test
fun `accountId is null after clear so no request carries an empty user id`() {
val um = createUserManager()
um.set(listOf<Byte>(1, 2, 3))
assertEquals(listOf<Byte>(1, 2, 3), um.accountId)

um.clear()

assertNull(um.accountId)
}

@Test
fun `an empty account id reads as missing`() {
val um = createUserManager()

um.set(NoId)

assertNull(um.accountId)
}
}
Loading
Loading