From ec665e15f50ffc71982b3fb41587e394b79e1d4d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 9 Sep 2026 15:42:17 -0400 Subject: [PATCH 1/2] fix(logging): record metadata values that were silently dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MetadataBuilder declared `infix fun String.to(value: Any)`. A non-null parameter makes the member inapplicable to a nullable argument, so the call resolved to kotlin.to instead, built a Pair and discarded it in statement position. The field never reached the log. It compiles clean and produces at most an unused-expression warning, so nothing flagged it. The declared type is what breaks it, not the runtime value: a String? holding "hello" was dropped just as silently as one holding null. An overload taking Any? alongside the existing Any is not possible — both erase to to(String, Object)V and Kotlin rejects the platform declaration clash. Widen the single member to Any? instead, recording NULL_PLACEHOLDER for a null. That keeps it the only applicable candidate, so kotlin.to can no longer win. Audited the repo by temporarily shadowing kotlin.to inside the builder with a @Deprecated candidate and compiling every module. Eight sites were dropping fields: NotificationService title, body CoinbaseOnRampController correlationId, responseBody, errorLink GooglePlayBillingClient purchases SwapViewModel errorMessage RealVerifiedFiatCalculator balance quarks Each is now explicit at the call site rather than leaning on the placeholder, so an absent value reads as what the field means: -1 for a count, "none" for an amount, "" where the file already used orEmpty. Push content stays out — TraceType.Process reaches breadcrumb sinks, so title and body become the derived `silent` and `has_body`. MetadataBuilderTest covers both nullable cases. Five of its seven tests fail if the parameter is narrowed back to Any. --- .../internal/GooglePlayBillingClient.kt | 2 +- .../app/notifications/NotificationService.kt | 6 +- .../app/onramp/CoinbaseOnRampController.kt | 6 +- .../flipcash/app/tokens/ui/SwapViewModel.kt | 2 +- .../main/kotlin/com/getcode/utils/Logging.kt | 18 ++- .../com/getcode/utils/MetadataBuilderTest.kt | 103 ++++++++++++++++++ .../exchange/RealVerifiedFiatCalculator.kt | 2 +- 7 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 libs/logging/src/test/kotlin/com/getcode/utils/MetadataBuilderTest.kt diff --git a/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/internal/GooglePlayBillingClient.kt b/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/internal/GooglePlayBillingClient.kt index 59f3c2940d..ccccc50081 100644 --- a/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/internal/GooglePlayBillingClient.kt +++ b/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/internal/GooglePlayBillingClient.kt @@ -106,7 +106,7 @@ internal class GooglePlayBillingClient( metadata = { "code" to billingResult.responseCode "message" to billingResult.debugMessage - "purchases" to purchases?.count() + "purchases" to (purchases?.count() ?: -1) } ) diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 02805ef5ca..c60f0f6122 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -139,8 +139,10 @@ class NotificationService : FirebaseMessagingService(), message = "onMessageReceived", type = TraceType.Process, metadata = { - "title" to title - "body" to body + // Push content is not recorded: TraceType.Process is forwarded to + // breadcrumb sinks, and message text does not belong in Bugsnag. + "silent" to (title == null) + "has_body" to (body != null) } ) diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt index 525bee8bc7..8f9b1f289d 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt @@ -324,9 +324,9 @@ class CoinbaseOnRampController @Inject constructor( "orderId" to orderId "httpCode" to error.code().toString() "errorType" to (coinbaseError?.let { it::class.simpleName } ?: "unknown") - "correlationId" to coinbaseError?.correlationId - "responseBody" to errorBody - "errorLink" to coinbaseError?.errorLink + "correlationId" to coinbaseError?.correlationId.orEmpty() + "responseBody" to errorBody.orEmpty() + "errorLink" to coinbaseError?.errorLink.orEmpty() }, error = error, ) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 9e4d5b4ffd..397a2915ff 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -1860,7 +1860,7 @@ class SwapViewModel @Inject constructor( message = "Something went wrong during phantom onramp", type = TraceType.Error, metadata = { - "errorMessage" to deeplinkError.message + "errorMessage" to deeplinkError.message.orEmpty() "code" to deeplinkError.code }, error = deeplinkError.takeUnless { it.isAlert } diff --git a/libs/logging/src/main/kotlin/com/getcode/utils/Logging.kt b/libs/logging/src/main/kotlin/com/getcode/utils/Logging.kt index c3e048f62c..636d9e5681 100644 --- a/libs/logging/src/main/kotlin/com/getcode/utils/Logging.kt +++ b/libs/logging/src/main/kotlin/com/getcode/utils/Logging.kt @@ -346,11 +346,25 @@ suspend fun timedTraceSuspend( class MetadataBuilder { private val map = mutableMapOf() - infix fun String.to(value: Any) { - map[this] = value + /** + * Records [value] under this key, substituting [NULL_PLACEHOLDER] when it is `null`. + * + * The parameter is deliberately nullable. A non-null `Any` parameter makes this + * function inapplicable to a nullable argument, so the call silently resolves to + * [kotlin.to] instead, building a [Pair] that is discarded in statement position — + * the field is dropped with no error and no warning. Accepting `Any?` keeps this + * member the only candidate, so every pair is recorded. + */ + infix fun String.to(value: Any?) { + map[this] = value ?: NULL_PLACEHOLDER } fun build(): Map = map + + companion object { + /** Recorded in place of a `null` metadata value so the field stays visible. */ + const val NULL_PLACEHOLDER = "null" + } } /** Convenience factory that builds a metadata map from [block]. */ diff --git a/libs/logging/src/test/kotlin/com/getcode/utils/MetadataBuilderTest.kt b/libs/logging/src/test/kotlin/com/getcode/utils/MetadataBuilderTest.kt new file mode 100644 index 0000000000..1e4009003d --- /dev/null +++ b/libs/logging/src/test/kotlin/com/getcode/utils/MetadataBuilderTest.kt @@ -0,0 +1,103 @@ +package com.getcode.utils + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Guards the overload resolution of [MetadataBuilder.to]. + * + * When the parameter was a non-null `Any`, a nullable argument made the member + * inapplicable and the call resolved to [kotlin.to] instead — building a [Pair] + * that was discarded in statement position. The field was dropped silently, with + * no error and at most an unused-expression warning. These tests fail if the + * parameter is ever narrowed back to a non-null type. + */ +class MetadataBuilderTest { + + private fun build(block: MetadataBuilder.() -> Unit): Map = + MetadataBuilder().apply(block).build() + + @Test + fun `nullable String holding a value is recorded`() { + val present: String? = "hello" + + val result = build { "value" to present } + + assertEquals(mapOf("value" to "hello"), result) + } + + @Test + fun `nullable String holding null is recorded as the placeholder`() { + val absent: String? = null + + val result = build { "value" to absent } + + assertEquals(mapOf("value" to MetadataBuilder.NULL_PLACEHOLDER), result) + } + + @Test + fun `both nullable cases appear in the same map`() { + val present: String? = "hello" + val absent: String? = null + + val result = build { + "present" to present + "absent" to absent + } + + assertEquals( + mapOf("present" to "hello", "absent" to MetadataBuilder.NULL_PLACEHOLDER), + result, + ) + } + + @Test + fun `non-null values are recorded unchanged`() { + val result = build { + "string" to "text" + "int" to 1 + "boolean" to true + } + + assertEquals(mapOf("string" to "text", "int" to 1, "boolean" to true), result) + } + + @Test + fun `nullable non-String types are recorded`() { + val count: Int? = null + val flag: Boolean? = false + + val result = build { + "count" to count + "flag" to flag + } + + assertEquals( + mapOf("count" to MetadataBuilder.NULL_PLACEHOLDER, "flag" to false), + result, + ) + } + + @Test + fun `every pair reaches the map`() { + val absent: String? = null + + val result = build { + "a" to "one" + "b" to absent + "c" to 3 + } + + assertEquals(3, result.size) + } + + @Test + fun `a repeated key keeps the last value`() { + val result = build { + "key" to "first" + "key" to "second" + } + + assertEquals(mapOf("key" to "second"), result) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt index 2eda845e6e..6767901e9b 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt @@ -171,7 +171,7 @@ internal class RealVerifiedFiatCalculator @Inject constructor( "original currency fx" to rate.fx "requested amount" to amount.formatted() "requested quarks (in USD)" to usdValue.quarks * 1_000_000 - "balance quarks (in USD)" to balance?.quarks?.times(1_000_000) + "balance quarks (in USD)" to (balance?.quarks?.times(1_000_000) ?: "none") "capped quarks (in USD)" to cappedValue.quarks * 1_000_000 "supply of ${token.symbol}" to supply "calculated quarks" to quarks From 2e97e359f5b0f8f9ad4a8f6f76c84d9de59fa993 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 9 Sep 2026 15:42:25 -0400 Subject: [PATCH 2/2] fix(workers): identify a funded gift card by vault address, not entropy GiftCardFundingWorker recorded `giftCard.entropy` in both funding traces. That value is mnemonic.getBase58EncodedEntropy(), the seed controlling the gift card's funds, and trace() with any type other than Silent forwards its metadata to breadcrumb sinks. It was reaching Bugsnag on every funding success and failure. The vault public key identifies the same account for debugging and is already public on-chain. The WorkManager tag built in tagFor still embeds entropy. That stays in the WorkManager database rather than going to a breadcrumb sink, so it is left alone here. --- .../app/workers/internal/GiftCardFundingWorker.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/shared/workers/src/main/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorker.kt b/apps/flipcash/shared/workers/src/main/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorker.kt index 5cc214d267..673bd67752 100644 --- a/apps/flipcash/shared/workers/src/main/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorker.kt +++ b/apps/flipcash/shared/workers/src/main/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorker.kt @@ -16,6 +16,7 @@ import com.getcode.opencode.model.accounts.entropy import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.base58 import com.getcode.utils.trace import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -148,7 +149,10 @@ internal class GiftCardFundingWorker @AssistedInject constructor( tag = "GiftCardFundingWorker", message = "Successfully funded gift card", metadata = { - "giftCard" to giftCard.entropy + // The vault address identifies the gift card without + // exposing its entropy, which is the seed controlling + // its funds. This trace reaches breadcrumb sinks. + "giftCardVault" to giftCard.cluster.vaultPublicKey.base58() } ) cont.resume(kotlin.Result.success(it)) @@ -158,7 +162,7 @@ internal class GiftCardFundingWorker @AssistedInject constructor( tag = "GiftCardFundingWorker", message = "Failed to fund gift card", metadata = { - "giftCard" to giftCard.entropy + "giftCardVault" to giftCard.cluster.vaultPublicKey.base58() }, error = it )