diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt index 1ba7a8e42b..d5b108cd57 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt @@ -1,6 +1,7 @@ package com.flipcash.app.core.feed import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.PublicKey @@ -109,18 +110,33 @@ sealed interface MessageMetadata { val userId: ID? = null, ) : MessageMetadata + /** + * @param swapMetadata When the withdrawal was executed as a swap, the metadata for that swap; + * null for a plain withdrawal. + */ @Serializable - data object WithdrewCrypto : MessageMetadata + data class WithdrewCrypto( + val swapMetadata: SwappedCryptoMetadata? = null, + ) : MessageMetadata @Serializable data object DepositedCrypto : MessageMetadata + // Superseded by [SwappedCrypto]; retained so historical bought/sold messages still deserialize. @Serializable data object BoughtToken: MessageMetadata @Serializable data object SoldToken: MessageMetadata + /** + * A swap between two mints, modeled as a single event. Supersedes [BoughtToken]/[SoldToken]. + */ + @Serializable + data class SwappedCrypto( + val swap: SwappedCryptoMetadata, + ): MessageMetadata + @Serializable data class PaidCrypto( val poolId: ID, @@ -136,4 +152,33 @@ sealed interface MessageMetadata { } } } -} \ No newline at end of file +} + +/** + * The state of a swap as a whole. Persisted mirror of `com.flipcash.services.models.SwapState`. + */ +enum class SwapState { + UNKNOWN, + PENDING, + SUCCEEDED, + FAILED, + NONE, +} + +/** + * Persisted mirror of `com.flipcash.services.models.SwappedCryptoMetadata`. + * + * @param from The amount the user gave up in the source mint. + * @param toMint The destination mint. Always known, even while the swap is pending. + * @param toAmount The amount received in the destination mint. Null until the swap has executed. + * @param fee The fee charged for the swap, known upfront regardless of swap state. + * @param swapState The state of the swap as a whole. + */ +@Serializable +data class SwappedCryptoMetadata( + val from: LocalFiat, + val toMint: PublicKey, + val toAmount: LocalFiat?, + val fee: Fiat, + val swapState: SwapState, +) \ No newline at end of file diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/feed/ActivityFeedMessageTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/feed/ActivityFeedMessageTest.kt index bfc48d831a..c81d7587ab 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/feed/ActivityFeedMessageTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/feed/ActivityFeedMessageTest.kt @@ -1,5 +1,10 @@ package com.flipcash.app.core.feed +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.solana.keys.PublicKey +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -75,7 +80,7 @@ class ActivityFeedMessageTest { fun metadataFromWithdrewCrypto() { val json = """{"type":"com.flipcash.app.core.feed.MessageMetadata.WithdrewCrypto"}""" val result = MessageMetadata.from(json) - assertEquals(MessageMetadata.WithdrewCrypto, result) + assertEquals(MessageMetadata.WithdrewCrypto(), result) } @Test @@ -98,4 +103,41 @@ class ActivityFeedMessageTest { val result = MessageMetadata.from(json) assertEquals(MessageMetadata.SoldToken, result) } + + // --- Swap metadata --- + + @Test + fun swappedCryptoRoundTrips() { + val original: MessageMetadata = MessageMetadata.SwappedCrypto( + swap = SwappedCryptoMetadata( + from = LocalFiat(usdf = Fiat(quarks = 5_000_000L), nativeAmount = Fiat(fiat = 5.0)), + toMint = PublicKey(ByteArray(32) { it.toByte() }.toList()), + toAmount = LocalFiat(usdf = Fiat(quarks = 4_900_000L), nativeAmount = Fiat(fiat = 4.9)), + fee = Fiat(fiat = 0.1), + swapState = SwapState.SUCCEEDED, + ) + ) + assertEquals(original, MessageMetadata.from(Json.encodeToString(original))) + } + + @Test + fun withdrewCryptoWithSwapMetadataRoundTrips() { + val original: MessageMetadata = MessageMetadata.WithdrewCrypto( + swapMetadata = SwappedCryptoMetadata( + from = LocalFiat(usdf = Fiat(quarks = 5_000_000L), nativeAmount = Fiat(fiat = 5.0)), + toMint = PublicKey(ByteArray(32) { it.toByte() }.toList()), + toAmount = null, + fee = Fiat(fiat = 0.1), + swapState = SwapState.PENDING, + ) + ) + assertEquals(original, MessageMetadata.from(Json.encodeToString(original))) + } + + // Legacy withdrew-crypto JSON (no swapMetadata) still decodes, with a null swap. + @Test + fun legacyWithdrewCryptoDecodesWithNullSwap() { + val json = """{"type":"com.flipcash.app.core.feed.MessageMetadata.WithdrewCrypto"}""" + assertEquals(MessageMetadata.WithdrewCrypto(swapMetadata = null), MessageMetadata.from(json)) + } } diff --git a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenLeaderboard.kt b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenLeaderboard.kt index 20cffbec51..6613c8a114 100644 --- a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenLeaderboard.kt +++ b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenLeaderboard.kt @@ -226,6 +226,11 @@ internal fun TokenLeaderboard( ), rank = index + 1, token = entry.token, + rankingSystem = if (isNewUi) { + RankingSystem.MarketCap + } else { + RankingSystem.Holders + }, ) { dispatch(TokenDiscoveryViewModel.Event.OpenTokenInfo(entry.token.address)) } diff --git a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenMetricsRow.kt b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenMetricsRow.kt index 1f64ba245b..0a1395a6e7 100644 --- a/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenMetricsRow.kt +++ b/apps/flipcash/features/discovery/src/main/kotlin/com/flipcash/app/discovery/internal/components/TokenMetricsRow.kt @@ -28,6 +28,7 @@ import com.flipcash.app.core.ui.rememberShimmerAlpha import com.flipcash.app.core.ui.shimmer import com.flipcash.app.core.util.abbreviated import com.flipcash.features.discovery.R +import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.ui.WindowedRange import com.getcode.theme.CodeTheme @@ -35,6 +36,7 @@ import com.getcode.ui.components.charts.LineTrend import com.getcode.ui.core.addIf import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding +import kotlin.math.abs sealed interface RankingSystem { object Holders : RankingSystem @@ -114,7 +116,53 @@ internal fun TokenMetricsRow( ) } RankingSystem.MarketCap -> { - // TODO: once we have market cap metrics cross window + val currencySymbol = CurrencyCode.USD.singleCharacterCurrencySymbol.orEmpty() + + val metricsDelta = + remember(token.marketCapMetrics) { token.marketCapMetrics.deltaForWindow(window) } + val change = if (metricsDelta >= 0) LineTrend.Up else LineTrend.Down + val deltaForWindow = buildString { + append(if (change == LineTrend.Up) "+" else "-") + append(currencySymbol) + append(abs(metricsDelta).abbreviated()) + append(" ") + append( + when (window) { + WindowedRange.AllTime -> stringResource(R.string.label_marketCapAllTime) + WindowedRange.LastDay -> stringResource(R.string.label_marketCapDay) + WindowedRange.LastWeek -> stringResource(R.string.label_marketCapWeek) + WindowedRange.LastMonth -> stringResource(R.string.label_marketCapMonth) + WindowedRange.LastYear -> stringResource(R.string.label_marketCapYear) + } + ) + } + + // Prefer the backend-provided market cap; fall back to the bonding-curve estimate + // for tokens that don't yet have market-cap metrics populated. + val currentCap = remember(token) { + token.marketCapMetrics.currentMarketCap.takeIf { it > 0.0 } + ?: token.marketCap()?.decimalValue + } + val value = currentCap?.let { "$currencySymbol${it.abbreviated()}" }.orEmpty() + + val subtitle = pluralStringResource( + R.plurals.subtitle_personCount, + token.holderMetrics.currentHolders.toInt(), + token.holderMetrics.currentHolders.abbreviated() + ) + + TokenMetricsRow( + modifier = modifier, + token = token, + subtitle = subtitle, + value = value, + valueChange = deltaForWindow, + valueChangeColor = when (change) { + LineTrend.Down -> CodeTheme.colors.textSecondary + LineTrend.Up -> change.color + }, + onClick = onClick, + ) } } } diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/28.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/28.json new file mode 100644 index 0000000000..d0d6346f2c --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/28.json @@ -0,0 +1,756 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "8ce3e8bbaa9d3503813ba3096c484e48", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + } + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8ce3e8bbaa9d3503813ba3096c484e48')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 3f4d72cbe1..d2237c009a 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -86,8 +86,9 @@ import com.getcode.utils.subByteArray // per-row user_profile_json blob into the shared user_profiles table, which // needs data movement an AutoMigration can't express. AutoMigration(from = 26, to = 27), // messages.text_substitutions (nullable) + AutoMigration(from = 27, to = 28), // tokens.market_cap_metrics (nullable) ], - version = 27, + version = 28, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/TokenTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/TokenTypeConverters.kt index dabb338c35..cbda4520e2 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/TokenTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/TokenTypeConverters.kt @@ -53,6 +53,18 @@ class TokenTypeConverters { } // endregion + // region market cap metrics + @TypeConverter + fun fromMarketCapMetrics(value: String?): MarketCapMetricsSerialized? { + return value?.let { json.decodeFromString(it) } + } + + @TypeConverter + fun toMarketCapMetrics(metrics: MarketCapMetricsSerialized?): String? { + return metrics?.let { json.encodeToString(it) } + } + // endregion + // region ModerationAttestations @TypeConverter fun fromModerationAttestations(value: String?): ModerationAttestationsSerialized? { @@ -122,6 +134,18 @@ data class HolderDeltaSerialized( val delta: Long, ) +@Serializable +data class MarketCapMetricsSerialized( + val currentMarketCap: Double, + val deltas: List, +) + +@Serializable +data class MarketCapDeltaSerialized( + val range: String, // WindowedRange enum name + val delta: Double, +) + @Serializable data class ModerationAttestationSerialized( val rawValue: List, diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/TokenEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/TokenEntity.kt index ef8f2021c2..0c8c89a5e0 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/TokenEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/TokenEntity.kt @@ -50,6 +50,9 @@ data class TokenEntity( @ColumnInfo(name = "holder_metrics") val holderMetricsJson: String?, + + @ColumnInfo(name = "market_cap_metrics") + val marketCapMetricsJson: String? = null, ) { @get:Ignore val mint: Mint diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt index e683cc7312..8640f6604d 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt @@ -2,10 +2,14 @@ package com.flipcash.app.persistence.sources.mapper.notifications import com.flipcash.app.core.feed.MessageMetadata import com.flipcash.app.core.feed.MessageSubstitution +import com.flipcash.app.core.feed.SwapState as MessageSwapState +import com.flipcash.app.core.feed.SwappedCryptoMetadata as MessageSwappedCryptoMetadata import com.flipcash.app.persistence.entities.MessageEntity import com.flipcash.services.models.ActivityFeedNotification import com.flipcash.services.models.NotificationMetadata import com.flipcash.services.models.Substitution +import com.flipcash.services.models.SwapState as ServiceSwapState +import com.flipcash.services.models.SwappedCryptoMetadata as ServiceSwappedCryptoMetadata import com.getcode.opencode.mapper.Mapper import com.getcode.solana.keys.Mint import com.getcode.solana.keys.base58 @@ -71,12 +75,29 @@ class MetadataMapper @Inject constructor(): Mapper MessageMetadata.ReceivedCrypto(from.phoneNumber, from.userId) is NotificationMetadata.IndirectlySentCrypto -> MessageMetadata.IndirectlySentCrypto(from.creator, from.canCancel) NotificationMetadata.Unknown -> MessageMetadata.Unknown - NotificationMetadata.WithdrewCrypto -> MessageMetadata.WithdrewCrypto + is NotificationMetadata.WithdrewCrypto -> MessageMetadata.WithdrewCrypto(from.swapMetadata?.toMessage()) NotificationMetadata.DepositedCrypto -> MessageMetadata.DepositedCrypto NotificationMetadata.BoughtToken -> MessageMetadata.BoughtToken NotificationMetadata.SoldToken -> MessageMetadata.SoldToken + is NotificationMetadata.SwappedCrypto -> MessageMetadata.SwappedCrypto(from.swap.toMessage()) } } + + /** Translates the service swap model to its persisted app-core mirror (shared payload types copy across). */ + private fun ServiceSwappedCryptoMetadata.toMessage(): MessageSwappedCryptoMetadata = + MessageSwappedCryptoMetadata( + from = from, + toMint = toMint, + toAmount = toAmount, + fee = fee, + swapState = when (swapState) { + ServiceSwapState.UNKNOWN -> MessageSwapState.UNKNOWN + ServiceSwapState.PENDING -> MessageSwapState.PENDING + ServiceSwapState.SUCCEEDED -> MessageSwapState.SUCCEEDED + ServiceSwapState.FAILED -> MessageSwapState.FAILED + ServiceSwapState.NONE -> MessageSwapState.NONE + }, + ) } private data class AmountHolder( diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/tokens/EntityToTokenMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/tokens/EntityToTokenMapper.kt index 3cff6338c9..92b4ccfe96 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/tokens/EntityToTokenMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/tokens/EntityToTokenMapper.kt @@ -4,6 +4,7 @@ import android.util.Base64 import com.flipcash.app.persistence.converters.BillBackgroundSerialized import com.flipcash.app.persistence.converters.BillCustomizationsSerialized import com.flipcash.app.persistence.converters.HolderMetricsSerialized +import com.flipcash.app.persistence.converters.MarketCapMetricsSerialized import com.flipcash.app.persistence.converters.SocialLinkSerialized import com.flipcash.app.persistence.embedded.LaunchpadMetadataEmbedded import com.flipcash.app.persistence.embedded.VmMetadataEmbedded @@ -13,6 +14,7 @@ import com.getcode.opencode.mapper.Mapper 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.MarketCapMetrics import com.getcode.opencode.model.financial.MintMetadata import com.getcode.opencode.model.financial.SocialLink import com.getcode.opencode.model.financial.VmMetadata @@ -50,7 +52,10 @@ class EntityToTokenMapper @Inject constructor() : Mapper(it) } - ?.toDomain() ?: HolderMetrics.None + ?.toDomain() ?: HolderMetrics.None, + marketCapMetrics = from.marketCapMetricsJson + ?.let { json.decodeFromString(it) } + ?.toDomain() ?: MarketCapMetrics.None ) } } @@ -105,4 +110,14 @@ private fun HolderMetricsSerialized.toDomain() = HolderMetrics( delta = delta.delta, ) }, +) + +private fun MarketCapMetricsSerialized.toDomain() = MarketCapMetrics( + currentMarketCap = currentMarketCap, + marketCapDeltas = deltas.map { delta -> + MarketCapMetrics.MarketCapDelta( + range = WindowedRange.valueOf(delta.range), + delta = delta.delta, + ) + }, ) \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/tokens/TokenToEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/tokens/TokenToEntityMapper.kt index 8741de0bda..eee1953820 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/tokens/TokenToEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/tokens/TokenToEntityMapper.kt @@ -6,6 +6,8 @@ import com.flipcash.app.persistence.converters.BillCustomizationsSerialized import com.flipcash.app.persistence.converters.BillTextureSerialized import com.flipcash.app.persistence.converters.HolderDeltaSerialized import com.flipcash.app.persistence.converters.HolderMetricsSerialized +import com.flipcash.app.persistence.converters.MarketCapDeltaSerialized +import com.flipcash.app.persistence.converters.MarketCapMetricsSerialized import com.flipcash.app.persistence.converters.SocialLinkSerialized import com.flipcash.app.persistence.embedded.LaunchpadMetadataEmbedded import com.flipcash.app.persistence.embedded.VmMetadataEmbedded @@ -13,6 +15,7 @@ import com.flipcash.app.persistence.entities.TokenEntity import com.getcode.opencode.mapper.Mapper import com.getcode.opencode.model.financial.HolderMetrics import com.getcode.opencode.model.financial.LaunchpadMetadata +import com.getcode.opencode.model.financial.MarketCapMetrics import com.getcode.opencode.model.financial.MintMetadata import com.getcode.opencode.model.financial.SocialLink import com.getcode.opencode.model.ui.BillBackground @@ -46,7 +49,10 @@ class TokenToEntityMapper @Inject constructor() : Mapper + MarketCapDeltaSerialized( + range = delta.range.name, + delta = delta.delta, + ) + }, ) \ No newline at end of file diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt index ccf3685c62..1a162f685a 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt @@ -111,8 +111,9 @@ private fun userIdOf(meta: MessageMetadata?): ID? = when (meta) { private fun hasNoCounterparty(meta: MessageMetadata?): Boolean = when (meta) { MessageMetadata.DepositedCrypto, - MessageMetadata.WithdrewCrypto, + is MessageMetadata.WithdrewCrypto, MessageMetadata.BoughtToken, + is MessageMetadata.SwappedCrypto, MessageMetadata.SoldToken -> true else -> false } @@ -122,8 +123,10 @@ val MessageMetadata.isOutgoing: Boolean get() = when (this) { is MessageMetadata.DirectlySentCrypto, is MessageMetadata.IndirectlySentCrypto, - MessageMetadata.WithdrewCrypto, + is MessageMetadata.WithdrewCrypto, MessageMetadata.SoldToken, + // A swap debits the source mint (the `from` side), so treat it as outgoing. + is MessageMetadata.SwappedCrypto, is MessageMetadata.PaidCrypto -> true is MessageMetadata.ReceivedCrypto, MessageMetadata.DepositedCrypto, diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/MessageDirectionTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/MessageDirectionTest.kt index 656fedf481..57e924f9b1 100644 --- a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/MessageDirectionTest.kt +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/MessageDirectionTest.kt @@ -12,7 +12,7 @@ class MessageDirectionTest { fun `sent variants are outgoing`() { assertEquals(true, MessageMetadata.DirectlySentCrypto(phoneNumber = null).isOutgoing) assertEquals(true, MessageMetadata.IndirectlySentCrypto(PublicKey(ByteArray(32).toList()), canCancel = true).isOutgoing) - assertEquals(true, MessageMetadata.WithdrewCrypto.isOutgoing) + assertEquals(true, MessageMetadata.WithdrewCrypto().isOutgoing) assertEquals(true, MessageMetadata.SoldToken.isOutgoing) assertEquals(true, MessageMetadata.PaidCrypto(poolId = listOf()).isOutgoing) } diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt index ad4ec34384..0f72190ba9 100644 --- a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt @@ -193,7 +193,7 @@ class TransactionItemMapperTest { @Test fun `withdraw uses the token icon and a minus prefix`() { val token = usdfToken() - val msg = feedMessage(metadata = MessageMetadata.WithdrewCrypto) + val msg = feedMessage(metadata = MessageMetadata.WithdrewCrypto()) val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to emptyMap()) assertEquals("-", item.signedAmountPrefix) diff --git a/definitions/flipcash/protos/src/main/proto/activity/v1/model.proto b/definitions/flipcash/protos/src/main/proto/activity/v1/model.proto index 9622feb7dd..1b92ef18b7 100644 --- a/definitions/flipcash/protos/src/main/proto/activity/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/activity/v1/model.proto @@ -30,6 +30,9 @@ message Notification { }]; // If a payment applies, the amount that was paid + // + // Note: For multi-mint operations, amounts are carried in additional_metadata + // (eg. swapped_crypto). common.v1.CryptoPaymentAmount payment_amount = 3; // The timestamp of this notification @@ -45,8 +48,9 @@ message Notification { WithdrewCryptoNotificationMetadata withdrew_crypto = 9; IndirectlySentCryptoNotificationMetadata indirectly_sent_crypto = 10; DepositedCryptoNotificationMetadata deposited_crypto = 11; - BoughtCryptoNotificationMetadata bought_crypto = 12; - SoldCryptoNotificationMetadata sold_crypto = 13; + BoughtCryptoNotificationMetadata bought_crypto = 12 [deprecated = true]; + SoldCryptoNotificationMetadata sold_crypto = 13 [deprecated = true]; + SwappedCryptoNotificationMetadata swapped_crypto = 14; } reserved 6; // Deprecated WelcomeBonusNotificationMetadata @@ -70,7 +74,11 @@ message ReceivedCryptoNotificationMetadata { } message WithdrewCryptoNotificationMetadata { + // Deprecated in favour of swap_metadata SwapState swap_state = 1 [(validate.rules).enum.not_in = 0]; + + // When a withdraw is a swap, the metadata for that swap + SwappedCryptoNotificationMetadata swap_metadata = 2; } message IndirectlySentCryptoNotificationMetadata { @@ -84,14 +92,46 @@ message IndirectlySentCryptoNotificationMetadata { message DepositedCryptoNotificationMetadata { } +// Deprecated: Use SwappedCryptoNotificationMetadata, which models both halves +// of the swap in a single notification. message BoughtCryptoNotificationMetadata { SwapState swap_state = 1 [(validate.rules).enum.not_in = 0]; } +// Deprecated: Use SwappedCryptoNotificationMetadata, which models both halves +// of the swap in a single notification. message SoldCryptoNotificationMetadata { SwapState swap_state = 1 [(validate.rules).enum.not_in = 0]; } +// SwappedCryptoNotificationMetadata represents a swap between two mints as a +// single notification. It supersedes BoughtCryptoNotificationMetadata and +// SoldCryptoNotificationMetadata, which modelled the two halves of a swap as +// separate notifications. +message SwappedCryptoNotificationMetadata { + // The amount the user gave up in the source mint + common.v1.CryptoPaymentAmount from = 1 [(validate.rules).message.required = true]; + + // What the user received in the destination mint. The mint is always known, + // but the amount is only known once the swap has executed. + oneof to { + option (validate.required) = true; + + // The destination mint, when the amount isn't yet known + common.v1.PublicKey to_mint = 2; + + // The amount the user received in the destination mint + common.v1.CryptoPaymentAmount to_amount = 3; + } + + // The fee charged for the swap, which is known upfront and is set regardless + // of the state of the swap + common.v1.FiatPaymentAmount fee = 4 [(validate.rules).message.required = true]; + + // The state of the swap as a whole + SwapState swap_state = 5 [(validate.rules).enum.not_in = 0]; +} + // ActivityFeedType enables multiple activity feeds, where notifications may be // split across different parts of the app enum ActivityFeedType { diff --git a/definitions/opencode/protos/src/main/proto/currency/v1/ocp_currency_service.proto b/definitions/opencode/protos/src/main/proto/currency/v1/ocp_currency_service.proto index 1abc130562..b2c9589c7d 100644 --- a/definitions/opencode/protos/src/main/proto/currency/v1/ocp_currency_service.proto +++ b/definitions/opencode/protos/src/main/proto/currency/v1/ocp_currency_service.proto @@ -198,6 +198,9 @@ message Mint { // Holder metrics. This is surfaced where needed (e.g. only in the Discover RPC) HolderMetrics holder_metrics = 12; + + // Market cap metrics. This is surfaced where needed (e.g. only in the Discover RPC) + MarketCapMetrics market_cap_metrics = 13; } message VmMetadata { @@ -442,6 +445,26 @@ message HolderMetrics { } } +message MarketCapMetrics { + // The current market capitalization in USD for a currency + double current_market_cap = 1; + + repeated DeltaMarketCap market_cap_deltas = 2 [(validate.rules).repeated = { + min_items: 0 + max_items: 4 + }]; + + + + message DeltaMarketCap { + // Predefined range where delta is calculated from + PredefinedRange range = 1; + + // Net change in market capitalization in USD within the time range + double delta = 2; + } +} + message LaunchRequest { // The owner account launching the currency common.v1.SolanaAccountId owner = 1 [(validate.rules).message.required = true]; diff --git a/definitions/opencode/protos/src/main/proto/transaction/v1/ocp_transaction_service.proto b/definitions/opencode/protos/src/main/proto/transaction/v1/ocp_transaction_service.proto index b0851e46a3..93b1ef69de 100644 --- a/definitions/opencode/protos/src/main/proto/transaction/v1/ocp_transaction_service.proto +++ b/definitions/opencode/protos/src/main/proto/transaction/v1/ocp_transaction_service.proto @@ -534,7 +534,7 @@ message StatefulSwapResponse { // // Instruction formats: // - // Buy Tokens (Core Mint -> Launchpad Currency Mint): + // Buy Tokens (Core Mint -> Launchpad Currency Mint) without a buy fee: // 1. System::AdvanceNonce // 2. [Optional] ComputeBudget::SetComputeUnitLimit // 3. [Optional] ComputeBudget::SetComputeUnitPrice @@ -545,6 +545,18 @@ message StatefulSwapResponse { // 8. Token::CloseAccount (closes Core Mint temporary account) // 9. VM::CloseSwapAccountIfEmpty (closes Core Mint VM swap ATA if empty) // + // Buy Tokens (Core Mint -> Launchpad Currency Mint) with a buy fee, which + // is used when fee_amount is non-zero: + // 1. System::AdvanceNonce + // 2. [Optional] ComputeBudget::SetComputeUnitLimit + // 3. [Optional] ComputeBudget::SetComputeUnitPrice + // 4. [Optional] Memo::Memo + // 5. AssociatedTokenAccount::CreateIdempotent (open Core Mint temporary account) + // 6. VM::TransferForSwapWithFee (Core Mint VM swap ATA -> Core Mint temporary account (swap amount) and fee destination (fee amount)) + // 7. Reserve::BuyAndDepositIntoVm (bounded buy of the swap amount depositing to_mint tokens into the to_mint VM) + // 8. Token::CloseAccount (closes Core Mint temporary account) + // 9. VM::CloseSwapAccountIfEmpty (closes Core Mint VM swap ATA if empty) + // // Sell Tokens (Launchpad Currency Mint -> Core Mint): // 1. System::AdvanceNonce // 2. [Optional] ComputeBudget::SetComputeUnitLimit @@ -609,6 +621,10 @@ message StatefulSwapResponse { // The memory index where the destination virtual Timelock account lives uint32 memory_index = 9; + + // Destination account where the buy fee should be paid. Only set when + // a non-zero fee_amount was provided in the client parameters. + common.v1.SolanaAccountId fee_destination = 10; } // Server parameters when executing stateful buy flows against the diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt index 728eaf6f30..336e25f47e 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt @@ -2,6 +2,8 @@ package com.flipcash.services.internal.domain import com.codeinc.flipcash.gen.activity.v1.Model import com.codeinc.flipcash.gen.activity.v1.paymentAmountOrNull +import com.codeinc.flipcash.gen.activity.v1.swapMetadataOrNull +import com.codeinc.flipcash.gen.common.v1.Common import com.codeinc.flipcash.gen.common.v1.mintOrNull import com.flipcash.libs.currency.math.units import com.flipcash.services.internal.domain.mapper.Mapper @@ -13,6 +15,8 @@ import com.flipcash.services.internal.network.extensions.toPublicKey import com.flipcash.services.models.ActivityFeedNotification import com.flipcash.services.models.NotificationMetadata import com.flipcash.services.models.NotificationState +import com.flipcash.services.models.SwapState +import com.flipcash.services.models.SwappedCryptoMetadata import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat @@ -28,30 +32,7 @@ internal class ActivityFeedMessageMapper @Inject constructor( return ActivityFeedNotification( id = from.id.toId(), text = from.localizedText, - amount = from.paymentAmountOrNull?.let { - val currencyCode = CurrencyCode.tryValueOf(it.currency) ?: CurrencyCode.USD - val tokenAmount = Fiat(quarks = it.quarks) - val nativeAmount = Fiat(fiat = it.nativeAmount, currencyCode) - // if no mint, or it's usdf, then we can operate as a normal localized Fiat - if (it.mintOrNull == Mint.usdf || it.mintOrNull == null) { - LocalFiat( - usdf = tokenAmount, - nativeAmount = nativeAmount, - ) - } else { - val units = BigDecimal(it.quarks).units() - val rate = Rate( - 1f / units.toDouble(), - currencyCode, - ) - LocalFiat( - underlyingTokenAmount = tokenAmount, - mint = it.mint.toMint(), - rate = rate, - nativeAmount = nativeAmount, - ) - } - }, + amount = from.paymentAmountOrNull?.let { localFiatOf(it) }, timestamp = Instant.fromEpochSeconds(from.ts.seconds), state = when (from.state) { Model.NotificationState.NOTIFICATION_STATE_PENDING -> NotificationState.PENDING @@ -83,7 +64,9 @@ internal class ActivityFeedMessageMapper @Inject constructor( ) meta.userId.value.toByteArray().toList() else null, ) } - Model.Notification.AdditionalMetadataCase.WITHDREW_CRYPTO -> NotificationMetadata.WithdrewCrypto + Model.Notification.AdditionalMetadataCase.WITHDREW_CRYPTO -> NotificationMetadata.WithdrewCrypto( + swapMetadata = from.withdrewCrypto.swapMetadataOrNull?.toDomain(), + ) Model.Notification.AdditionalMetadataCase.INDIRECTLY_SENT_CRYPTO -> NotificationMetadata.IndirectlySentCrypto( creator = from.indirectlySentCrypto.vault.value.toByteArray().toPublicKey(), canCancel = from.indirectlySentCrypto.canInitiateCancelAction @@ -91,10 +74,77 @@ internal class ActivityFeedMessageMapper @Inject constructor( Model.Notification.AdditionalMetadataCase.DEPOSITED_CRYPTO -> NotificationMetadata.DepositedCrypto Model.Notification.AdditionalMetadataCase.BOUGHT_CRYPTO -> NotificationMetadata.BoughtToken Model.Notification.AdditionalMetadataCase.SOLD_CRYPTO -> NotificationMetadata.SoldToken + Model.Notification.AdditionalMetadataCase.SWAPPED_CRYPTO -> NotificationMetadata.SwappedCrypto( + swap = from.swappedCrypto.toDomain(), + ) Model.Notification.AdditionalMetadataCase.ADDITIONALMETADATA_NOT_SET, null -> NotificationMetadata.Unknown }, textSubstitutions = from.textSubstitutionsList.mapNotNull { it.asSubstitution() }, ) } +} + +/** + * Builds a [LocalFiat] from a proto [Common.CryptoPaymentAmount]: usdf (or no mint) collapses to a + * plain localized Fiat; any other mint carries its own token amount, mint, and derived rate. + */ +private fun localFiatOf(amount: Common.CryptoPaymentAmount): LocalFiat { + val currencyCode = CurrencyCode.tryValueOf(amount.currency) ?: CurrencyCode.USD + val tokenAmount = Fiat(quarks = amount.quarks) + val nativeAmount = Fiat(fiat = amount.nativeAmount, currencyCode) + // if no mint, or it's usdf, then we can operate as a normal localized Fiat + return if (amount.mintOrNull == Mint.usdf || amount.mintOrNull == null) { + LocalFiat( + usdf = tokenAmount, + nativeAmount = nativeAmount, + ) + } else { + val units = BigDecimal(amount.quarks).units() + val rate = Rate( + 1f / units.toDouble(), + currencyCode, + ) + LocalFiat( + underlyingTokenAmount = tokenAmount, + mint = amount.mint.toMint(), + rate = rate, + nativeAmount = nativeAmount, + ) + } +} + +private fun Common.FiatPaymentAmount.toFiat(): Fiat { + val currencyCode = CurrencyCode.tryValueOf(currency) ?: CurrencyCode.USD + return Fiat(fiat = nativeAmount, currencyCode) +} + +/** + * Maps a proto swap notification to [SwappedCryptoMetadata]. The destination mint is always known; + * the received amount ([SwappedCryptoMetadata.toAmount]) is only present once the swap has executed + * (proto `to_amount`), otherwise only the mint (`to_mint`) is carried. + */ +private fun Model.SwappedCryptoNotificationMetadata.toDomain(): SwappedCryptoMetadata { + val executed = toCase == Model.SwappedCryptoNotificationMetadata.ToCase.TO_AMOUNT + return SwappedCryptoMetadata( + from = localFiatOf(from), + toMint = if (executed) { + toAmount.mint.value.toByteArray().toPublicKey() + } else { + toMint.value.toByteArray().toPublicKey() + }, + toAmount = if (executed) localFiatOf(toAmount) else null, + fee = fee.toFiat(), + swapState = swapState.toDomain(), + ) +} + +private fun Model.SwapState.toDomain(): SwapState = when (this) { + Model.SwapState.SWAP_STATE_PENDING -> SwapState.PENDING + Model.SwapState.SWAP_STATE_SUCCEEDED -> SwapState.SUCCEEDED + Model.SwapState.SWAP_STATE_FAILED -> SwapState.FAILED + Model.SwapState.SWAP_STATE_NONE -> SwapState.NONE + Model.SwapState.SWAP_STATE_UNKNOWN, + Model.SwapState.UNRECOGNIZED, + null -> SwapState.UNKNOWN } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt index 6a5af564bb..b7b1cbfd3f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt @@ -1,6 +1,7 @@ package com.flipcash.services.models import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat import com.getcode.solana.keys.PublicKey import kotlin.time.Instant @@ -78,12 +79,57 @@ sealed interface NotificationMetadata { val userId: ID? = null, ) : NotificationMetadata + /** + * @param swapMetadata When the withdrawal was executed as a swap, the metadata for that swap. + * Null for a plain withdrawal. (The server's deprecated per-half `swap_state` is superseded by + * this.) + */ @Serializable - data object WithdrewCrypto : NotificationMetadata + data class WithdrewCrypto( + val swapMetadata: SwappedCryptoMetadata? = null, + ) : NotificationMetadata + // Superseded by [SwappedCrypto] (which models both halves of a swap as one event). Retained so + // historical bought/sold notifications still map. @Serializable data object BoughtToken: NotificationMetadata @Serializable data object SoldToken: NotificationMetadata -} \ No newline at end of file + /** + * A swap between two mints, modeled as a single event. Supersedes [BoughtToken]/[SoldToken]. + */ + @Serializable + data class SwappedCrypto( + val swap: SwappedCryptoMetadata, + ) : NotificationMetadata +} + +/** + * The state of a swap as a whole. + */ +enum class SwapState { + UNKNOWN, + PENDING, + SUCCEEDED, + FAILED, + NONE, +} + +/** + * Details of a crypto swap between two mints. + * + * @param from The amount the user gave up in the source mint. + * @param toMint The destination mint. Always known, even while the swap is pending. + * @param toAmount The amount received in the destination mint. Null until the swap has executed. + * @param fee The fee charged for the swap, known upfront regardless of swap state. + * @param swapState The state of the swap as a whole. + */ +@Serializable +data class SwappedCryptoMetadata( + val from: LocalFiat, + val toMint: PublicKey, + val toAmount: LocalFiat?, + val fee: Fiat, + val swapState: SwapState, +) \ No newline at end of file diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt index 1c9092728f..d79bbb3768 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt @@ -10,10 +10,13 @@ import com.codeinc.flipcash.gen.activity.v1.withdrewCryptoNotificationMetadata import com.codeinc.flipcash.gen.activity.v1.depositedCryptoNotificationMetadata import com.codeinc.flipcash.gen.activity.v1.boughtCryptoNotificationMetadata import com.codeinc.flipcash.gen.activity.v1.soldCryptoNotificationMetadata +import com.codeinc.flipcash.gen.activity.v1.swappedCryptoNotificationMetadata import com.codeinc.flipcash.gen.common.v1.cryptoPaymentAmount +import com.codeinc.flipcash.gen.common.v1.fiatPaymentAmount import com.codeinc.flipcash.gen.common.v1.publicKey import com.flipcash.services.models.NotificationMetadata import com.flipcash.services.models.NotificationState +import com.flipcash.services.models.SwapState import com.google.protobuf.ByteString import com.google.protobuf.Timestamp import org.junit.Test @@ -149,7 +152,7 @@ class ActivityFeedMessageMapperTest { val proto = baseNotification { withdrewCrypto = withdrewCryptoNotificationMetadata {} } - assertEquals(NotificationMetadata.WithdrewCrypto, mapper.map(proto).metadata) + assertEquals(NotificationMetadata.WithdrewCrypto(), mapper.map(proto).metadata) } @Test @@ -176,6 +179,90 @@ class ActivityFeedMessageMapperTest { assertEquals(NotificationMetadata.SoldToken, mapper.map(proto).metadata) } + @Test + fun `swapped crypto executed carries the received amount`() { + val proto = baseNotification { + swappedCrypto = swappedCryptoNotificationMetadata { + from = cryptoPaymentAmount { + currency = "USD" + nativeAmount = 5.0 + quarks = 5_000_000L + } + toAmount = cryptoPaymentAmount { + currency = "USD" + nativeAmount = 4.9 + quarks = 4_900_000L + } + fee = fiatPaymentAmount { + currency = "USD" + nativeAmount = 0.1 + } + swapState = Model.SwapState.SWAP_STATE_SUCCEEDED + } + } + + val meta = mapper.map(proto).metadata + assertIs(meta) + assertEquals(SwapState.SUCCEEDED, meta.swap.swapState) + assertEquals(5_000_000L, meta.swap.from.underlyingTokenAmount.quarks) + assertNotNull(meta.swap.toAmount) + assertEquals(4_900_000L, meta.swap.toAmount.underlyingTokenAmount.quarks) + } + + @Test + fun `swapped crypto pending carries only the destination mint`() { + val proto = baseNotification { + swappedCrypto = swappedCryptoNotificationMetadata { + from = cryptoPaymentAmount { + currency = "USD" + nativeAmount = 5.0 + quarks = 5_000_000L + } + toMint = publicKey { value = ByteString.copyFrom(ByteArray(32) { it.toByte() }) } + fee = fiatPaymentAmount { + currency = "USD" + nativeAmount = 0.1 + } + swapState = Model.SwapState.SWAP_STATE_PENDING + } + } + + val meta = mapper.map(proto).metadata + assertIs(meta) + assertEquals(SwapState.PENDING, meta.swap.swapState) + assertNull(meta.swap.toAmount) + } + + @Test + fun `withdrew crypto carries swap metadata when present`() { + val proto = baseNotification { + withdrewCrypto = withdrewCryptoNotificationMetadata { + swapMetadata = swappedCryptoNotificationMetadata { + from = cryptoPaymentAmount { + currency = "USD" + nativeAmount = 5.0 + quarks = 5_000_000L + } + toAmount = cryptoPaymentAmount { + currency = "USD" + nativeAmount = 4.9 + quarks = 4_900_000L + } + fee = fiatPaymentAmount { + currency = "USD" + nativeAmount = 0.1 + } + swapState = Model.SwapState.SWAP_STATE_SUCCEEDED + } + } + } + + val meta = mapper.map(proto).metadata + assertIs(meta) + assertNotNull(meta.swapMetadata) + assertEquals(SwapState.SUCCEEDED, meta.swapMetadata.swapState) + } + @Test fun `indirectly sent crypto metadata has creator and canCancel`() { val vaultBytes = ByteArray(32) { 42 } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/RepositoryFactory.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/RepositoryFactory.kt index 8d05966844..4d0fa240ea 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/RepositoryFactory.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/RepositoryFactory.kt @@ -5,6 +5,7 @@ import com.getcode.opencode.inject.OpenCodeModule import com.getcode.opencode.internal.domain.mapping.BillCustomizationMapper import com.getcode.opencode.internal.domain.mapping.HistoricalMintDataMapper import com.getcode.opencode.internal.domain.mapping.HolderMetricsMapper +import com.getcode.opencode.internal.domain.mapping.MarketCapMetricsMapper import com.getcode.opencode.internal.domain.mapping.LaunchpadMetadataMapper import com.getcode.opencode.internal.domain.mapping.LiveMintDataMapper import com.getcode.opencode.internal.domain.mapping.MintMapper @@ -147,6 +148,7 @@ object RepositoryFactory { socialLinkMapper = SocialLinkMapper(), customizationMapper = BillCustomizationMapper(), holderMetricsMapper = HolderMetricsMapper(), + marketCapMetricsMapper = MarketCapMetricsMapper(), ) val historicalMintDataMapper = HistoricalMintDataMapper() val liveMintDataMapper = LiveMintDataMapper() diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/MessagingController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/MessagingController.kt index 4174c8aa6a..d543271c26 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/MessagingController.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/MessagingController.kt @@ -4,6 +4,7 @@ import com.codeinc.opencode.gen.messaging.v1.OcpMessagingService import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.internal.domain.mapping.BillCustomizationMapper import com.getcode.opencode.internal.domain.mapping.HolderMetricsMapper +import com.getcode.opencode.internal.domain.mapping.MarketCapMetricsMapper import com.getcode.opencode.internal.domain.mapping.LaunchpadMetadataMapper import com.getcode.opencode.internal.domain.mapping.MintMapper import com.getcode.opencode.internal.domain.mapping.SocialLinkMapper @@ -51,6 +52,7 @@ class MessagingController @Inject constructor( socialLinkMapper = SocialLinkMapper(), customizationMapper = BillCustomizationMapper(), holderMetricsMapper = HolderMetricsMapper(), + marketCapMetricsMapper = MarketCapMetricsMapper(), ) suspend fun awaitRequestToGrabBill( diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/mapping/MarketCapMetricsMapper.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/mapping/MarketCapMetricsMapper.kt new file mode 100644 index 0000000000..8cf6189eab --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/mapping/MarketCapMetricsMapper.kt @@ -0,0 +1,29 @@ +package com.getcode.opencode.internal.domain.mapping + +import com.codeinc.opencode.gen.currency.v1.OcpCurrencyService +import com.getcode.opencode.model.ui.WindowedRange +import com.getcode.opencode.mapper.Mapper +import com.getcode.opencode.model.financial.MarketCapMetrics +import javax.inject.Inject + +internal class MarketCapMetricsMapper @Inject constructor() : + Mapper { + override fun map(from: OcpCurrencyService.MarketCapMetrics): MarketCapMetrics { + return MarketCapMetrics( + currentMarketCap = from.currentMarketCap, + marketCapDeltas = from.marketCapDeltasList.mapNotNull { + MarketCapMetrics.MarketCapDelta( + range = when (it.range) { + OcpCurrencyService.PredefinedRange.ALL_TIME -> WindowedRange.AllTime + OcpCurrencyService.PredefinedRange.LAST_DAY -> WindowedRange.LastDay + OcpCurrencyService.PredefinedRange.LAST_WEEK -> WindowedRange.LastWeek + OcpCurrencyService.PredefinedRange.LAST_MONTH -> WindowedRange.LastMonth + OcpCurrencyService.PredefinedRange.LAST_YEAR -> WindowedRange.LastYear + OcpCurrencyService.PredefinedRange.UNRECOGNIZED -> return@mapNotNull null + }, + delta = it.delta + ) + } + ) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/mapping/MintMapper.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/mapping/MintMapper.kt index 54cc34908b..4637232e49 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/mapping/MintMapper.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/mapping/MintMapper.kt @@ -4,9 +4,11 @@ import com.codeinc.opencode.gen.currency.v1.OcpCurrencyService import com.codeinc.opencode.gen.currency.v1.billCustomizationOrNull import com.codeinc.opencode.gen.currency.v1.holderMetricsOrNull import com.codeinc.opencode.gen.currency.v1.launchpadMetadataOrNull +import com.codeinc.opencode.gen.currency.v1.marketCapMetricsOrNull import com.getcode.opencode.internal.network.extensions.toMint import com.getcode.opencode.mapper.Mapper import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.MarketCapMetrics import com.getcode.opencode.model.financial.MintMetadata import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.usdf @@ -22,6 +24,7 @@ internal class MintMapper @Inject constructor( private val socialLinkMapper: SocialLinkMapper, private val customizationMapper: BillCustomizationMapper, private val holderMetricsMapper: HolderMetricsMapper, + private val marketCapMetricsMapper: MarketCapMetricsMapper, ) : Mapper { override fun map(from: OcpCurrencyService.Mint): MintMetadata { val mint = from.address.toMint() @@ -55,6 +58,7 @@ internal class MintMapper @Inject constructor( socialLinks = from.socialLinksList.mapNotNull(socialLinkMapper::map), billCustomizations = customizationMapper.map(from.billCustomizationOrNull), holderMetrics = from.holderMetricsOrNull?.let { holderMetricsMapper.map(it) } ?: HolderMetrics.None, + marketCapMetrics = from.marketCapMetricsOrNull?.let { marketCapMetricsMapper.map(it) } ?: MarketCapMetrics.None, ) } } \ No newline at end of file diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt index f531972b68..f45832b989 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt @@ -147,6 +147,7 @@ internal fun OcpTransactionService.StatefulSwapResponse.ServerParameters.Reserve memoValue = memoValue, memoryAccount = memoryAccount.toPublicKey(), memoryIndex = memoryIndex, + feeDestination = if (hasFeeDestination()) feeDestination.toPublicKey() else null, ) } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/ExtractServerParameters.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/ExtractServerParameters.kt index 902c80873c..6c544cf72f 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/ExtractServerParameters.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/ExtractServerParameters.kt @@ -19,6 +19,7 @@ sealed interface ExtractedServerParams { override val memo: String, val memoryAccount: PublicKey, val memoryIndex: Int, + val feeDestination: PublicKey? = null, ): ExtractedServerParams data class NewCurrency( @@ -48,6 +49,7 @@ internal fun extractServerParameters(serverParameters: StatefulSwapResponseServe memo = serverParameters.memoValue, memoryAccount = serverParameters.memoryAccount, memoryIndex = serverParameters.memoryIndex, + feeDestination = serverParameters.feeDestination, ) } 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 d239fa4b1b..fcf261dede 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 @@ -165,6 +165,7 @@ data class MintMetadata( val billCustomizations: TokenBillCustomizations?, val socialLinks: List, val holderMetrics: HolderMetrics, + val marketCapMetrics: MarketCapMetrics = MarketCapMetrics.None, ) : Parcelable { fun marketCap(): Fiat? { val launchpad = launchpadMetadata ?: return null @@ -274,4 +275,24 @@ data class HolderMetrics( val range: WindowedRange, val delta: Long, ) : Parcelable +} + +@Parcelize +data class MarketCapMetrics( + val currentMarketCap: Double, + val marketCapDeltas: List, +) : Parcelable { + companion object { + val None = MarketCapMetrics(0.0, emptyList()) + } + + fun deltaForWindow(window: WindowedRange): Double { + return marketCapDeltas.firstOrNull { it.range == window }?.delta ?: 0.0 + } + + @Parcelize + data class MarketCapDelta( + val range: WindowedRange, + val delta: Double, + ) : Parcelable } \ No newline at end of file diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapResponseServerParameters.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapResponseServerParameters.kt index 6fd02c5b1c..0ee869c55a 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapResponseServerParameters.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapResponseServerParameters.kt @@ -120,6 +120,12 @@ sealed interface StatefulSwapResponseServerParameters { * The memory index where the destination virtual Timelock account lives */ val memoryIndex: Int, + /** + * Destination account where the buy fee should be paid. Only set by the server when a + * non-zero fee applies to the buy; null otherwise. When present, the buy uses + * `VM::TransferForSwapWithFee` instead of `VM::TransferForSwap`. + */ + val feeDestination: PublicKey? = null, ): StatefulSwapResponseServerParameters /** diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt index 64dc824d5e..136ae9666f 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt @@ -35,6 +35,9 @@ object TransactionBuilder { * @param swapAuthority The public key of the temporary swap authority derived from the nonce. * @param route The route of the swap (Buy or Sell) and the target/source mint involved. * @param amount The amount of tokens to swap (in the source currency's smallest unit). + * @param feeAmount The buy fee to collect from the core mint, in quarks (Buy route only). When + * greater than 0 and the server provided a fee destination, the buy routes the + * fee via `VM::TransferForSwapWithFee`. Defaults to 0 (no fee). * @param minOutput The minimum acceptable amount of output tokens to receive (slippage protection). * Defaults to 0. * @return A constructed [SolanaTransaction] (V0) ready to be signed and submitted to the network. @@ -45,6 +48,7 @@ object TransactionBuilder { swapAuthority: PublicKey, route: SwapRoute, amount: Long, + feeAmount: Long = 0, minOutput: Long = 0, ): SolanaTransaction { val coreMint = Token.usdf @@ -59,6 +63,7 @@ object TransactionBuilder { targetMintMetadata = route.mint, amount = amount, minOutput = minOutput, + feeAmount = feeAmount, ) is SwapRoute.Sell -> buildSellInstructions( diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/ExistingCurrencyBuyInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/ExistingCurrencyBuyInstructions.kt index 5f2ba26248..f6bf32017d 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/ExistingCurrencyBuyInstructions.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/ExistingCurrencyBuyInstructions.kt @@ -11,6 +11,7 @@ import com.getcode.opencode.internal.solana.programs.SystemProgram_AdvanceNonce import com.getcode.opencode.internal.solana.programs.TokenProgram_CloseAccount import com.getcode.opencode.internal.solana.programs.VirtualMachineProgram_CloseSwapAccountIfEmpty import com.getcode.opencode.internal.solana.programs.VirtualMachineProgram_TransferForSwap +import com.getcode.opencode.internal.solana.programs.VirtualMachineProgram_TransferForSwapWithFee import com.getcode.opencode.model.financial.MintMetadata import com.getcode.opencode.model.transactions.StatefulSwapResponseServerParameters import com.getcode.opencode.solana.Instruction @@ -37,6 +38,10 @@ import com.getcode.solana.keys.PublicKey * @param targetMintMetadata Metadata for the target currency (destination). * @param amount The amount of core currency to spend. * @param minOutput The minimum amount of target currency to receive. + * @param feeAmount The buy fee to collect from the core mint, in quarks. When greater than 0 and the + * server provided a [StatefulSwapResponseServerParameters.ExistingCurrency.feeDestination], the swap + * transfer uses `VM::TransferForSwapWithFee` (routing the fee to that destination) instead of the + * plain `VM::TransferForSwap`. Defaults to 0 (no fee), which preserves the original instruction set. * @return A list of [Instruction]s to execute the buy operation. */ internal fun buildExistingCurrencyBuyInstructions( @@ -48,6 +53,7 @@ internal fun buildExistingCurrencyBuyInstructions( targetMintMetadata: MintMetadata, amount: Long, minOutput: Long, + feeAmount: Long = 0, ): List { val coreVm = coreMintMetadata.vmMetadata val targetVm = targetMintMetadata.vmMetadata @@ -80,19 +86,38 @@ internal fun buildExistingCurrencyBuyInstructions( // 5. AssociatedTokenAccount::CreateIdempotent (open Core Mint temporary account) add(createTemporaryCoreMintAta.instruction()) - // 6. VM::TransferForSwap (Core Mint VM swap ATA -> Core Mint temporary account) - add( - VirtualMachineProgram_TransferForSwap( - vmAuthority = coreVm.authority, - vm = coreVm.vm, - swapper = authority, - swapPda = coreTimelockAccounts.pda.publicKey, - swapAta = coreTimelockAccounts.ata.publicKey, - destination = createTemporaryCoreMintAta.address, - amount = amount, - bump = coreTimelockAccounts.pda.bump, - ).instruction() - ) + // 6. VM::TransferForSwap[WithFee] (Core Mint VM swap ATA -> Core Mint temporary account, + // plus the fee to the fee destination when a buy fee applies) + val feeDestination = serverParameters.feeDestination + if (feeAmount > 0 && feeDestination != null) { + add( + VirtualMachineProgram_TransferForSwapWithFee( + vmAuthority = coreVm.authority, + vm = coreVm.vm, + swapper = authority, + swapPda = coreTimelockAccounts.pda.publicKey, + swapAta = coreTimelockAccounts.ata.publicKey, + destination = createTemporaryCoreMintAta.address, + feeDestination = feeDestination, + swapAmount = amount, + feeAmount = feeAmount, + bump = coreTimelockAccounts.pda.bump, + ).instruction() + ) + } else { + add( + VirtualMachineProgram_TransferForSwap( + vmAuthority = coreVm.authority, + vm = coreVm.vm, + swapper = authority, + swapPda = coreTimelockAccounts.pda.publicKey, + swapAta = coreTimelockAccounts.ata.publicKey, + destination = createTemporaryCoreMintAta.address, + amount = amount, + bump = coreTimelockAccounts.pda.bump, + ).instruction() + ) + } // 7. CurrencyCreator::BuyAndDepositIntoVm add(