From 529bc4b6c7542311f3a64ae8d83642e292ca461a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 20:50:10 -0400 Subject: [PATCH] fix(validation): stop sending empty user ids and short mints, and check inputs before the RPC --- .../internal/CurrencyCreatorViewModel.kt | 5 +- .../currencycreator/internal/CurrencyName.kt | 22 +++++++++ .../internal/CurrencyNameTest.kt | 49 +++++++++++++++++++ .../profile/edit/EditGroupNameViewModel.kt | 1 + .../screens/profile/edit/NameEditScaffold.kt | 1 + .../screens/profile/edit/PartialEdits.kt | 1 + .../tipping/internal/CreateGroupViewModel.kt | 4 +- .../tipping/internal/CreateGroupStateTest.kt | 17 +++++++ .../internal/name/DisplayNameLimit.kt | 14 ++++++ .../internal/name/NameEntryViewModel.kt | 10 ++-- .../internal/name/NameEntryStateTest.kt | 18 +++++++ .../com/flipcash/shared/chat}/ChatTitle.kt | 4 +- .../flipcash/shared/chat}/ChatTitleTest.kt | 2 +- .../flipcash/app/router/internal/AppRouter.kt | 7 ++- .../app/router/internal/AppRouterTest.kt | 10 ++++ .../flipcash/app/tokens/TokenCoordinator.kt | 13 +++++ .../com/getcode/solana/keys/MintTest.kt | 13 +++++ .../kotlin/com/getcode/solana/keys/Mint.kt | 7 +++ .../flipcash/services/user/UserManagerTest.kt | 21 ++++++++ .../com/flipcash/services/user/UserManager.kt | 8 ++- 20 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyName.kt create mode 100644 apps/flipcash/features/currency-creator/src/test/kotlin/com/flipcash/app/currencycreator/internal/CurrencyNameTest.kt create mode 100644 apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/DisplayNameLimit.kt rename apps/flipcash/{features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit => shared/chat/src/main/kotlin/com/flipcash/shared/chat}/ChatTitle.kt (94%) rename apps/flipcash/{features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit => shared/chat/src/test/kotlin/com/flipcash/shared/chat}/ChatTitleTest.kt (97%) diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt index 91a8d44b0f..06239034b9 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt @@ -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() @@ -318,6 +319,8 @@ internal class CurrencyCreatorViewModel @Inject constructor( eventFlow .filterIsInstance() + // 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()) } diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyName.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyName.kt new file mode 100644 index 0000000000..edcca6e03a --- /dev/null +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyName.kt @@ -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) + } +} diff --git a/apps/flipcash/features/currency-creator/src/test/kotlin/com/flipcash/app/currencycreator/internal/CurrencyNameTest.kt b/apps/flipcash/features/currency-creator/src/test/kotlin/com/flipcash/app/currencycreator/internal/CurrencyNameTest.kt new file mode 100644 index 0000000000..24d49a41dd --- /dev/null +++ b/apps/flipcash/features/currency-creator/src/test/kotlin/com/flipcash/app/currencycreator/internal/CurrencyNameTest.kt @@ -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")) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt index d55ffb3bf6..1b47cca22d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupNameViewModel.kt @@ -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 diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/NameEditScaffold.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/NameEditScaffold.kt index 05813a5eee..52cd78a019 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/NameEditScaffold.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/NameEditScaffold.kt @@ -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 diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEdits.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEdits.kt index e2dbeff4d6..61ea96a516 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEdits.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/PartialEdits.kt @@ -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 diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt index 4befa8a403..13d26ffdb8 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt @@ -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 @@ -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 diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupStateTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupStateTest.kt index 2975c66e73..94c21b65a9 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupStateTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupStateTest.kt @@ -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))) diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/DisplayNameLimit.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/DisplayNameLimit.kt new file mode 100644 index 0000000000..80512398d7 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/DisplayNameLimit.kt @@ -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 diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt index 470f60ec28..8768a3513d 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt @@ -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. */ @@ -124,9 +125,8 @@ class NameEntryViewModel @Inject constructor( .filterIsInstance() .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()) { diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryStateTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryStateTest.kt index b9e91c9105..cd77b26a46 100644 --- a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryStateTest.kt +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryStateTest.kt @@ -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 @@ -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)) + } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatTitle.kt similarity index 94% rename from apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt rename to apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatTitle.kt index 6f4c7ede49..f053af1da5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitle.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatTitle.kt @@ -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. @@ -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 diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitleTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatTitleTest.kt similarity index 97% rename from apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitleTest.kt rename to apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatTitleTest.kt index d4dd0bd34f..85e60b9044 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/ChatTitleTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatTitleTest.kt @@ -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 diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index 56dfdcba82..2e59f402b9 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -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} diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index 92296991ef..6d4197b6fb 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -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 diff --git a/apps/flipcash/shared/tokens/src/commonMain/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt b/apps/flipcash/shared/tokens/src/commonMain/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt index 1ef303004e..9ed68ea86f 100644 --- a/apps/flipcash/shared/tokens/src/commonMain/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt +++ b/apps/flipcash/shared/tokens/src/commonMain/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt @@ -448,6 +448,8 @@ class TokenCoordinator @Inject constructor( * eventually replaced with fresh data. */ override suspend fun getTokenMetadata(mint: Mint): Result { + if (!mint.isValid) return invalidMint("getTokenMetadata", mint) + // 1. In-memory cache _state.value.tokens[mint]?.let { cached -> return Result.success(TokenResult(cached, DataSource.Memory)) @@ -524,9 +526,20 @@ class TokenCoordinator @Inject constructor( currencyCode: CurrencyCode, windowedRange: WindowedRange, ): Result> { + 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 { + 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() diff --git a/libs/encryption/keys/src/androidHostTest/kotlin/com/getcode/solana/keys/MintTest.kt b/libs/encryption/keys/src/androidHostTest/kotlin/com/getcode/solana/keys/MintTest.kt index c4cccf2abc..2feadcb0e5 100644 --- a/libs/encryption/keys/src/androidHostTest/kotlin/com/getcode/solana/keys/MintTest.kt +++ b/libs/encryption/keys/src/androidHostTest/kotlin/com/getcode/solana/keys/MintTest.kt @@ -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 { @@ -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) + } } diff --git a/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Mint.kt b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Mint.kt index b28294443e..6b78c918ce 100644 --- a/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Mint.kt +++ b/libs/encryption/keys/src/commonMain/kotlin/com/getcode/solana/keys/Mint.kt @@ -9,6 +9,13 @@ import kotlinx.serialization.Serializable class Mint(bytes: List): 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(it) }) { parcel -> Mint(parcel.readString().orEmpty()) } diff --git a/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/user/UserManagerTest.kt b/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/user/UserManagerTest.kt index fde805c379..70aefbcee2 100644 --- a/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/user/UserManagerTest.kt +++ b/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/user/UserManagerTest.kt @@ -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 @@ -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(1, 2, 3)) + assertEquals(listOf(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) + } } diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/user/UserManager.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/user/UserManager.kt index 260abee535..270157b587 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/user/UserManager.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/user/UserManager.kt @@ -98,8 +98,12 @@ class UserManager @Inject constructor( val accountCluster: AccountCluster? get() = _state.value.cluster + /** + * The signed-in user's id, or null when there is none. An empty id (the old [NoId] reset + * value) counts as none: it fails the server's `user_id` length rule if sent. + */ val accountId: ID? - get() = _state.value.accountId + get() = _state.value.accountId?.takeIf { it.isNotEmpty() } val userFlags: UserFlags? get() = _state.value.flags @@ -233,7 +237,7 @@ class UserManager @Inject constructor( entropy = null, flags = null, cluster = null, - accountId = NoId, + accountId = null, isTimelockUnlocked = false, userProfile = null, )