Skip to content

feat: add jetton management functionality including metadata resoluti…#118

Open
nikdim03 wants to merge 5 commits into
mainfrom
feature/ton-1703-jetton-manager
Open

feat: add jetton management functionality including metadata resoluti…#118
nikdim03 wants to merge 5 commits into
mainfrom
feature/ton-1703-jetton-manager

Conversation

@nikdim03

@nikdim03 nikdim03 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

…on and address validation

Summary by CodeRabbit

  • New Features

    • Jettons API for resolving token metadata, listing user holdings with pagination, and validating token addresses
    • Gasless transaction support now verifies wallet version compatibility; unsupported wallets have gasless UI disabled
  • Chores

    • Standardized error handling for DeFi operations with properly typed exceptions and error codes

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a new ITONJettonsManager interface and full stack implementation (engine, bridge, constants, request/response models), exposes it via ITONWalletKit.jettons(), and consumes it in SendTokensViewModel to load fee-asset metadata for the gasless send flow. Gasless is now gated on wallet version (W5/v5r1). The JS bridge bundle standardizes all DeFi manager errors to typed DefiError instances. API codegen removes backfill_generic_type_refs and adds x-generic-instance-type template support.

Changes

Jettons API and Gasless Send UI

Layer / File(s) Summary
ITONJettonsManager interface and ITONWalletKit.jettons() entry point
TONWalletKit-Android/api/src/main/java/io/ton/walletkit/jettons/ITONJettonsManager.kt, TONWalletKit-Android/api/src/main/java/io/ton/walletkit/ITONWalletKit.kt
New ITONJettonsManager interface with suspend methods for jetton info, address jettons, and address validation; ITONWalletKit gains suspend fun jettons(): ITONJettonsManager.
WalletKitEngine declarations, bridge constants, request/response models, and RPC operations
TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/WalletKitEngine.kt, TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/internal/constants/BridgeMethodConstants.kt, TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/requests/JettonsRequests.kt, TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/BridgeResponses.kt, TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/JettonsOperations.kt, TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/WebViewWalletKitEngine.kt
Adds jettonInfo, addressJettons, and validateJettonAddress to WalletKitEngine; defines three bridge method constants; introduces serializable request models and ValidateJettonAddressResponse; implements BridgeRpcClient extension helpers; overrides in WebViewWalletKitEngine delegate to rpcClient.
TONJettonsManager implementation and TONWalletKit wiring
TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/jettons/TONJettonsManager.kt, TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/core/TONWalletKit.kt
TONJettonsManager delegates all three operations to WalletKitEngine; TONWalletKit creates the manager and exposes it via jettons() override.
SendTokensViewModel gasless gating, fee-asset metadata, and max computation refactor
AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/SendTokensViewModel.kt
Adds gaslessSupported to constructor and UiState; replaces jettonMeta with resolvedFeeAssets; adds loadFeeAssetMetadata() using kit.jettons(); adds acknowledgeSent(); gates setGaslessEnabled on gaslessSupported; refactors computeGaslessMax to probe relay before computing max; changes quote() to accept explicit recipient/transferAmountRaw.
SendTransactionScreen gasless UI wiring and conditional GaslessCard
AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/SendTransactionScreen.kt
Computes gaslessSupported from wallet version (WalletVersions.V5R1); passes it to SendTokensContent and factory; adds LaunchedEffect to trigger loadFeeAssetMetadata() when fee assets arrive; renders GaslessCard with conditional label and dimmed toggle when unsupported.

DeFi Bridge Error Standardization

Layer / File(s) Summary
toDefiError helper, DefiErrorCode.Unknown, and DefiManager typed throws
TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs
Adds toDefiError() wrapper, DefiErrorCode.Unknown; DefiManager provider registration, setDefaultProvider, and getProvider now throw DefiError with specific codes.
SwapManager, StakingManager, GaslessManager, CryptoOnrampManager error rethrow updates
TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs
All four DeFi manager catch blocks rethrow via toDefiError(); module init order updated to call init_errors$4(); adds CryptoOnrampErrorCode.DepositFailed; provider factories and staking APY parsing throw typed errors.

API Code Generation: x-generic-instance-type Support

Layer / File(s) Summary
Remove backfill_generic_type_refs and add x-generic-instance-type template branch
Scripts/generate-api/generate-api-models.sh, Scripts/generate-api/templates/modelGeneric.mustache
The shell script drops the backfill_generic_type_refs() post-processing step; the Mustache template gains a new conditional branch emitting a var property typed as modelNamePrefix + x-generic-instance-type.

Sequence Diagram(s)

sequenceDiagram
  rect rgba(173, 216, 230, 0.5)
    Note over SendTransactionScreen,ITONJettonsManager: Fee-asset metadata loading
    SendTransactionScreen->>SendTokensViewModel: loadFeeAssetMetadata() [LaunchedEffect on feeAssets.size]
    SendTokensViewModel->>ITONJettonsManager: jettonInfo(feeAssetAddress, network)
    ITONJettonsManager-->>SendTokensViewModel: TONJettonInfo (symbol, decimals, icon)
    SendTokensViewModel->>SendTokensViewModel: resolvedFeeAssets updated, _state.feeAssets refreshed
  end
  rect rgba(255, 220, 180, 0.5)
    Note over SendTransactionScreen,GaslessRelay: Gasless max computation
    SendTransactionScreen->>SendTokensViewModel: useMax() [gasless enabled]
    SendTokensViewModel->>GaslessRelay: quote(probeAmount = balance/2) to determine feeRaw
    GaslessRelay-->>SendTokensViewModel: feeRaw
    SendTokensViewModel->>SendTokensViewModel: amount = (balanceRaw - feeRaw), scheduleQuote()
  end
  rect rgba(200, 255, 200, 0.5)
    Note over SendTransactionScreen,SendTokensViewModel: Post-send acknowledgement
    SendTransactionScreen->>SendTokensViewModel: acknowledgeSent() [on state.sent == true]
    SendTokensViewModel->>SendTokensViewModel: sent flag cleared
    SendTransactionScreen->>SendTransactionScreen: onBack()
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • ton-org/kit-android#114: Directly related — both PRs modify SendTransactionScreen and the gasless send flow to carry a gaslessSupported flag through to the ViewModel and UI components.

Suggested reviewers

  • ProudOfZiggy
  • TrueCarry

Poem

🐰 Hoppity-hop through the wallet stack,
New jettons manager leads the pack!
Gasless gated on W5 alone,
Typed DefiErrors replace the unknown.
The bridge now speaks in structured woe —
Every error has a name, you know! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main feature being added: jetton management functionality with metadata resolution capabilities.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/ton-1703-jetton-manager

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nikdim03 nikdim03 changed the base branch from feature/ton-1674-demo-redesign to main June 18, 2026 10:11
@ProudOfZiggy

Copy link
Copy Markdown
Contributor

@nikdim03 Where is new TONJettonInfo models? I don't see it in PR

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/SendTransactionScreen.kt (1)

278-282: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider using a more explicit disabled state for the switch.

The disabled switch (line 281) uses checked = false with an empty onCheckedChange = {} lambda and visual dimming via alpha(0.4f). While functional, this pattern may not clearly convey the disabled state to accessibility services or future maintainers. Consider whether TonSwitch supports an explicit enabled parameter or a semantic disabled state.

♻️ Possible improvement if TonSwitch supports enabled parameter
TonSwitch(
    checked = state.gaslessEnabled, 
    onCheckedChange = viewModel::setGaslessEnabled,
    enabled = state.gaslessSupported
)

If TonSwitch does not have an enabled parameter, the current approach is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/SendTransactionScreen.kt`
around lines 278 - 282, The disabled TonSwitch in the else branch uses an empty
onCheckedChange lambda and alpha modifier to convey disabled state, which may
not be clear to accessibility services. Check if the TonSwitch component
supports an explicit enabled parameter. If it does, refactor the else branch to
remove the conditional logic and instead add enabled = state.gaslessSupported
directly to the TonSwitch call, along with onCheckedChange =
viewModel::setGaslessEnabled and checked = state.gaslessEnabled in both
branches. This will provide explicit semantic information about the disabled
state rather than relying on visual dimming.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/SendTokensViewModel.kt`:
- Around line 361-399: In the computeGaslessMax function, the fee parsing from
quote().fee is using toBigIntegerOrNull() with a silent fallback to
BigInteger.ZERO via the elvis operator, which masks parsing failures and causes
incorrect maxRaw calculation when the fee cannot be parsed. Replace the silent
fallback with explicit error handling that throws an exception or sets an error
state when the fee string cannot be parsed to BigInteger, ensuring that
malformed fee responses from the relay fail explicitly rather than allowing the
user to incorrectly send their entire balance without accounting for the fee.
- Around line 288-323: The loadFeeAssetMetadata function launches multiple
concurrent coroutines that each write to the shared resolvedFeeAssets mutable
map without synchronization, creating a race condition. Replace the
initialization of the resolvedFeeAssets field (currently mutableMapOf()) with
ConcurrentHashMap() to provide thread-safe concurrent access, and add the import
statement for java.util.concurrent.ConcurrentHashMap at the top of the file.

In
`@TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/BridgeResponses.kt`:
- Around line 100-101: The ValidateJettonAddressResponse data class has a
default value of false assigned to the valid field, which can mask protocol
failures when the field is unexpectedly omitted from the response. Remove the
default value assignment from the valid field in ValidateJettonAddressResponse
to make it a required field, ensuring that deserialization fails clearly if the
JS bridge omits the valid field rather than silently defaulting to false.

---

Nitpick comments:
In
`@AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/SendTransactionScreen.kt`:
- Around line 278-282: The disabled TonSwitch in the else branch uses an empty
onCheckedChange lambda and alpha modifier to convey disabled state, which may
not be clear to accessibility services. Check if the TonSwitch component
supports an explicit enabled parameter. If it does, refactor the else branch to
remove the conditional logic and instead add enabled = state.gaslessSupported
directly to the TonSwitch call, along with onCheckedChange =
viewModel::setGaslessEnabled and checked = state.gaslessEnabled in both
branches. This will provide explicit semantic information about the disabled
state rather than relying on visual dimming.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: edddcf25-1db8-4b52-88b4-5112781c7b1c

📥 Commits

Reviewing files that changed from the base of the PR and between 334abc3 and 516459c.

📒 Files selected for processing (15)
  • AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/SendTransactionScreen.kt
  • AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/SendTokensViewModel.kt
  • Scripts/generate-api/generate-api-models.sh
  • Scripts/generate-api/templates/modelGeneric.mustache
  • TONWalletKit-Android/api/src/main/java/io/ton/walletkit/ITONWalletKit.kt
  • TONWalletKit-Android/api/src/main/java/io/ton/walletkit/jettons/ITONJettonsManager.kt
  • TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs
  • TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/core/TONWalletKit.kt
  • TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/WalletKitEngine.kt
  • TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/WebViewWalletKitEngine.kt
  • TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/JettonsOperations.kt
  • TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/requests/JettonsRequests.kt
  • TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/BridgeResponses.kt
  • TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/internal/constants/BridgeMethodConstants.kt
  • TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/jettons/TONJettonsManager.kt
💤 Files with no reviewable changes (1)
  • Scripts/generate-api/generate-api-models.sh

Comment on lines +288 to +323
/**
* Resolve fee-asset jetton metadata (ticker / decimals / icon) via [ITONWalletKit.jettons].
*/
fun loadFeeAssetMetadata() {
val pending = _state.value.feeAssets.filter { it.address !in resolvedFeeAssets }
if (pending.isEmpty()) return
viewModelScope.launch {
val manager = kit.jettons()
val network = wallet.network()
pending.forEach { asset ->
launch {
runCatching {
val info = manager.jettonInfo(TONUserFriendlyAddress(asset.address), network)
?: return@launch
val symbol = info.symbol.ifEmpty { info.name }.ifEmpty { asset.symbol }
val resolved = asset.copy(
symbol = symbol,
decimals = info.decimals ?: asset.decimals,
imageSource = info.image ?: asset.imageSource,
)
resolvedFeeAssets[resolved.address] = resolved
_state.update { state ->
state.copy(
feeAssets = state.feeAssets.map { if (it.address == resolved.address) resolved else it },
selectedFeeAsset = state.selectedFeeAsset
?.takeIf { it.address == resolved.address }
?.let { resolved }
?: state.selectedFeeAsset,
)
}
}.onFailure { Log.e(TAG, "Failed to load jetton info for ${asset.address}", it) }
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Race condition on mutable map from concurrent coroutines.

Multiple coroutines are launched in parallel (line 298) and each writes to the shared resolvedFeeAssets mutable map (line 308) without synchronization. MutableMap is not thread-safe for concurrent writes, which can lead to lost updates or map corruption.

🔒 Recommended fix using synchronized access
 fun loadFeeAssetMetadata() {
     val pending = _state.value.feeAssets.filter { it.address !in resolvedFeeAssets }
     if (pending.isEmpty()) return
     viewModelScope.launch {
         val manager = kit.jettons()
         val network = wallet.network()
         pending.forEach { asset ->
             launch {
                 runCatching {
                     val info = manager.jettonInfo(TONUserFriendlyAddress(asset.address), network)
                         ?: return@launch
                     val symbol = info.symbol.ifEmpty { info.name }.ifEmpty { asset.symbol }
                     val resolved = asset.copy(
                         symbol = symbol,
                         decimals = info.decimals ?: asset.decimals,
                         imageSource = info.image ?: asset.imageSource,
                     )
-                    resolvedFeeAssets[resolved.address] = resolved
+                    synchronized(resolvedFeeAssets) {
+                        resolvedFeeAssets[resolved.address] = resolved
+                    }
                     _state.update { state ->
                         state.copy(
                             feeAssets = state.feeAssets.map { if (it.address == resolved.address) resolved else it },
                             selectedFeeAsset = state.selectedFeeAsset
                                 ?.takeIf { it.address == resolved.address }
                                 ?.let { resolved }
                                 ?: state.selectedFeeAsset,
                         )
                     }
                 }.onFailure { Log.e(TAG, "Failed to load jetton info for ${asset.address}", it) }
             }
         }
     }
 }

Alternatively, replace mutableMapOf() at line 99 with ConcurrentHashMap() (requires import java.util.concurrent.ConcurrentHashMap).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Resolve fee-asset jetton metadata (ticker / decimals / icon) via [ITONWalletKit.jettons].
*/
fun loadFeeAssetMetadata() {
val pending = _state.value.feeAssets.filter { it.address !in resolvedFeeAssets }
if (pending.isEmpty()) return
viewModelScope.launch {
val manager = kit.jettons()
val network = wallet.network()
pending.forEach { asset ->
launch {
runCatching {
val info = manager.jettonInfo(TONUserFriendlyAddress(asset.address), network)
?: return@launch
val symbol = info.symbol.ifEmpty { info.name }.ifEmpty { asset.symbol }
val resolved = asset.copy(
symbol = symbol,
decimals = info.decimals ?: asset.decimals,
imageSource = info.image ?: asset.imageSource,
)
resolvedFeeAssets[resolved.address] = resolved
_state.update { state ->
state.copy(
feeAssets = state.feeAssets.map { if (it.address == resolved.address) resolved else it },
selectedFeeAsset = state.selectedFeeAsset
?.takeIf { it.address == resolved.address }
?.let { resolved }
?: state.selectedFeeAsset,
)
}
}.onFailure { Log.e(TAG, "Failed to load jetton info for ${asset.address}", it) }
}
}
}
}
/**
* Resolve fee-asset jetton metadata (ticker / decimals / icon) via [ITONWalletKit.jettons].
*/
fun loadFeeAssetMetadata() {
val pending = _state.value.feeAssets.filter { it.address !in resolvedFeeAssets }
if (pending.isEmpty()) return
viewModelScope.launch {
val manager = kit.jettons()
val network = wallet.network()
pending.forEach { asset ->
launch {
runCatching {
val info = manager.jettonInfo(TONUserFriendlyAddress(asset.address), network)
?: return@launch
val symbol = info.symbol.ifEmpty { info.name }.ifEmpty { asset.symbol }
val resolved = asset.copy(
symbol = symbol,
decimals = info.decimals ?: asset.decimals,
imageSource = info.image ?: asset.imageSource,
)
synchronized(resolvedFeeAssets) {
resolvedFeeAssets[resolved.address] = resolved
}
_state.update { state ->
state.copy(
feeAssets = state.feeAssets.map { if (it.address == resolved.address) resolved else it },
selectedFeeAsset = state.selectedFeeAsset
?.takeIf { it.address == resolved.address }
?.let { resolved }
?: state.selectedFeeAsset,
)
}
}.onFailure { Log.e(TAG, "Failed to load jetton info for ${asset.address}", it) }
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/SendTokensViewModel.kt`
around lines 288 - 323, The loadFeeAssetMetadata function launches multiple
concurrent coroutines that each write to the shared resolvedFeeAssets mutable
map without synchronization, creating a race condition. Replace the
initialization of the resolvedFeeAssets field (currently mutableMapOf()) with
ConcurrentHashMap() to provide thread-safe concurrent access, and add the import
statement for java.util.concurrent.ConcurrentHashMap at the top of the file.

Comment on lines +361 to +399
/**
* Compute "max" for a gasless transfer whose fee is paid in the jetton being sent: probe the relay
* for the (amount- and recipient-independent) fee, then set the amount to balance − fee. Probing
* with an affordable amount avoids the HTTP 400 the relay returns when transfer + fee > balance.
*/
private fun computeGaslessMax(token: SendableToken, feeAsset: FeeAsset, relay: TONUserFriendlyAddress) {
quoteJob?.cancel()
quoteJob = viewModelScope.launch {
_state.update { it.copy(isQuoting = true, gaslessError = null) }
runCatching {
val gasless = ensureGasless()
val balanceRaw = toRaw(token.displayBalance, token.decimals).toBigInteger()
// Fee is gas-based — independent of amount and recipient — so probe with half the balance
// (always affordable) to our own address when no recipient has been entered yet.
val recipient = _state.value.recipient.trim().takeIf { isValidRecipient(it) }
?: wallet.address().value
val probeAmount = (balanceRaw / BigInteger.valueOf(2)).max(BigInteger.ONE)
val feeRaw = quote(gasless, token, feeAsset, recipient, probeAmount.toString(), relay)
.fee.toBigIntegerOrNull() ?: BigInteger.ZERO
val maxRaw = balanceRaw - feeRaw
if (maxRaw <= BigInteger.ZERO) {
_state.update {
it.copy(
isQuoting = false,
gaslessFeeText = null,
gaslessError = "Balance too low to cover the gasless fee",
)
}
return@launch
}
// Set the amount only; the fee is shown by scheduleQuote() once a recipient is entered.
_state.update { it.copy(amount = formatRaw(maxRaw.toString(), token.decimals), isQuoting = false) }
scheduleQuote()
}.onFailure {
Log.e(TAG, "Failed to compute gasless max", it)
_state.update { it.copy(isQuoting = false, gaslessError = "Failed to estimate gasless fee") }
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fee parsing failure is silently converted to zero.

At lines 378-379, if quote().fee cannot be parsed to BigInteger, it silently falls back to BigInteger.ZERO, which would incorrectly compute maxRaw = balanceRaw (line 380) and allow sending more than the user actually has (since the fee would not be subtracted). While this is unlikely if the gasless provider returns well-formed fee strings, it degrades correctness guarantees.

🛡️ Proposed fix to handle fee parsing errors explicitly
-                val feeRaw = quote(gasless, token, feeAsset, recipient, probeAmount.toString(), relay)
-                    .fee.toBigIntegerOrNull() ?: BigInteger.ZERO
+                val quoteResult = quote(gasless, token, feeAsset, recipient, probeAmount.toString(), relay)
+                val feeRaw = quoteResult.fee.toBigIntegerOrNull()
+                    ?: throw IllegalStateException("Invalid fee from gasless provider: ${quoteResult.fee}")
                 val maxRaw = balanceRaw - feeRaw

This ensures that malformed fee responses fail explicitly rather than silently computing an incorrect maximum.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Compute "max" for a gasless transfer whose fee is paid in the jetton being sent: probe the relay
* for the (amount- and recipient-independent) fee, then set the amount to balance − fee. Probing
* with an affordable amount avoids the HTTP 400 the relay returns when transfer + fee > balance.
*/
private fun computeGaslessMax(token: SendableToken, feeAsset: FeeAsset, relay: TONUserFriendlyAddress) {
quoteJob?.cancel()
quoteJob = viewModelScope.launch {
_state.update { it.copy(isQuoting = true, gaslessError = null) }
runCatching {
val gasless = ensureGasless()
val balanceRaw = toRaw(token.displayBalance, token.decimals).toBigInteger()
// Fee is gas-based — independent of amount and recipient — so probe with half the balance
// (always affordable) to our own address when no recipient has been entered yet.
val recipient = _state.value.recipient.trim().takeIf { isValidRecipient(it) }
?: wallet.address().value
val probeAmount = (balanceRaw / BigInteger.valueOf(2)).max(BigInteger.ONE)
val feeRaw = quote(gasless, token, feeAsset, recipient, probeAmount.toString(), relay)
.fee.toBigIntegerOrNull() ?: BigInteger.ZERO
val maxRaw = balanceRaw - feeRaw
if (maxRaw <= BigInteger.ZERO) {
_state.update {
it.copy(
isQuoting = false,
gaslessFeeText = null,
gaslessError = "Balance too low to cover the gasless fee",
)
}
return@launch
}
// Set the amount only; the fee is shown by scheduleQuote() once a recipient is entered.
_state.update { it.copy(amount = formatRaw(maxRaw.toString(), token.decimals), isQuoting = false) }
scheduleQuote()
}.onFailure {
Log.e(TAG, "Failed to compute gasless max", it)
_state.update { it.copy(isQuoting = false, gaslessError = "Failed to estimate gasless fee") }
}
}
}
/**
* Compute "max" for a gasless transfer whose fee is paid in the jetton being sent: probe the relay
* for the (amount- and recipient-independent) fee, then set the amount to balance − fee. Probing
* with an affordable amount avoids the HTTP 400 the relay returns when transfer + fee > balance.
*/
private fun computeGaslessMax(token: SendableToken, feeAsset: FeeAsset, relay: TONUserFriendlyAddress) {
quoteJob?.cancel()
quoteJob = viewModelScope.launch {
_state.update { it.copy(isQuoting = true, gaslessError = null) }
runCatching {
val gasless = ensureGasless()
val balanceRaw = toRaw(token.displayBalance, token.decimals).toBigInteger()
// Fee is gas-based — independent of amount and recipient — so probe with half the balance
// (always affordable) to our own address when no recipient has been entered yet.
val recipient = _state.value.recipient.trim().takeIf { isValidRecipient(it) }
?: wallet.address().value
val probeAmount = (balanceRaw / BigInteger.valueOf(2)).max(BigInteger.ONE)
val quoteResult = quote(gasless, token, feeAsset, recipient, probeAmount.toString(), relay)
val feeRaw = quoteResult.fee.toBigIntegerOrNull()
?: throw IllegalStateException("Invalid fee from gasless provider: ${quoteResult.fee}")
val maxRaw = balanceRaw - feeRaw
if (maxRaw <= BigInteger.ZERO) {
_state.update {
it.copy(
isQuoting = false,
gaslessFeeText = null,
gaslessError = "Balance too low to cover the gasless fee",
)
}
return@launch
}
// Set the amount only; the fee is shown by scheduleQuote() once a recipient is entered.
_state.update { it.copy(amount = formatRaw(maxRaw.toString(), token.decimals), isQuoting = false) }
scheduleQuote()
}.onFailure {
Log.e(TAG, "Failed to compute gasless max", it)
_state.update { it.copy(isQuoting = false, gaslessError = "Failed to estimate gasless fee") }
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/SendTokensViewModel.kt`
around lines 361 - 399, In the computeGaslessMax function, the fee parsing from
quote().fee is using toBigIntegerOrNull() with a silent fallback to
BigInteger.ZERO via the elvis operator, which masks parsing failures and causes
incorrect maxRaw calculation when the fee cannot be parsed. Replace the silent
fallback with explicit error handling that throws an exception or sets an error
state when the fee string cannot be parsed to BigInteger, ensuring that
malformed fee responses from the relay fail explicitly rather than allowing the
user to incorrectly send their entire balance without accounting for the fee.

Comment on lines +100 to +101
@Serializable
internal data class ValidateJettonAddressResponse(val valid: Boolean = false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether bridge payload for validateJettonAddress always includes `valid`
# and confirm decode path used by callTyped.

set -euo pipefail

echo "== Locate validateJettonAddress implementation in JS bridge =="
rg -n -C3 '\bvalidateJettonAddress\b' TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs

echo
echo "== Locate callTyped implementation and decode behavior =="
fd -i 'BridgeRpcClient.kt|RpcClient.kt|.*Operations.kt' TONWalletKit-Android/impl/src/main/java | xargs rg -n -C3 '\bcallTyped\b|\bdecodeFromString\b|\bdecodeFromJsonElement\b'

echo
echo "== Locate all usages of ValidateJettonAddressResponse =="
rg -n -C2 '\bValidateJettonAddressResponse\b'

Repository: ton-org/kit-android

Length of output: 50375


🏁 Script executed:

# Check the complete ValidateJettonAddressResponse definition and similar response classes
rg -A2 -B2 'internal data class.*Response.*valid|internal data class.*Response.*Boolean' TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/ | head -100

# Also check how kotlinx.serialization handles defaults
rg -C5 '`@Serializable`' TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/BridgeResponses.kt | grep -A3 -B3 'Boolean\|valid'

Repository: ton-org/kit-android

Length of output: 1411


🏁 Script executed:

# Check the JS bridge to see all code paths for validateJettonAddress and whether valid could be undefined
cat TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs | sed -n '40870,40875p'

# Check if there are other response models that intentionally DON'T have defaults
rg 'data class.*Response\(.*Boolean\)' TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/ | head -20

Repository: ton-org/kit-android

Length of output: 288


🏁 Script executed:

# Check the underlying jettons.validateJettonAddress implementation
rg -B5 -A10 'validateJettonAddress.*try\|validateJettonAddress\(.*{' TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs | head -30

# Check if there are response classes WITHOUT defaults to see the pattern
rg 'internal data class.*Response\(' TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/BridgeResponses.kt | grep -v '= ' | head -20

# Check callers of validateJettonAddress to see how errors are handled
rg -B3 -A3 'validateJettonAddress\(' TONWalletKit-Android/impl/src/main/java | grep -v '.mjs'

Repository: ton-org/kit-android

Length of output: 4257


🏁 Script executed:

# Look at response classes without defaults to understand the pattern
rg -A3 'internal data class.*Response\(' TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/BridgeResponses.kt | grep -A3 'KeyPairResponse\|ProviderIdResponse\|SignerInfoResponse'

# Search for the jettons implementation in the bridge to see if validateJettonAddress can fail or return undefined
rg -B10 'validateJettonAddress\(' TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs | grep -A10 'validateJettonAddress' | head -20

Repository: ton-org/kit-android

Length of output: 1298


🏁 Script executed:

# Check the underlying jettons implementation more carefully
rg -B20 'validateJettonAddress\(' TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs | tail -30

# Check if there's error handling around getKit() that could fail
rg -B5 -A5 'getKit\(\)' TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs | head -40

# Look at the actual ton/jettons validateJettonAddress method from the bridge to understand what it returns
rg -C3 'isTonAddress\|Address.parse' TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs | head -30

Repository: ton-org/kit-android

Length of output: 2429


🏁 Script executed:

# Check if HasProviderResponse has the same pattern and if it should also be fixed
rg -B5 -A10 'internal data class HasProviderResponse' TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/BridgeResponses.kt

# Check callers of hasSwapProvider to understand error handling
rg 'hasSwapProvider' TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/ -A3 -B1

Repository: ton-org/kit-android

Length of output: 1186


Remove the = false default from ValidateJettonAddressResponse.valid.

The JS bridge implementation always includes the valid field in its response object. A default value masks protocol/schema regressions as legitimate "invalid address" results when the field is unexpectedly omitted, conflating two semantically distinct failure modes. Required response fields should not have defaults.

Proposed fix
 `@Serializable`
-internal data class ValidateJettonAddressResponse(val valid: Boolean = false)
+internal data class ValidateJettonAddressResponse(val valid: Boolean)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Serializable
internal data class ValidateJettonAddressResponse(val valid: Boolean = false)
`@Serializable`
internal data class ValidateJettonAddressResponse(val valid: Boolean)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/BridgeResponses.kt`
around lines 100 - 101, The ValidateJettonAddressResponse data class has a
default value of false assigned to the valid field, which can mask protocol
failures when the field is unexpectedly omitted from the response. Remove the
default value assignment from the valid field in ValidateJettonAddressResponse
to make it a required field, ensuring that deserialization fails clearly if the
JS bridge omits the valid field rather than silently defaulting to false.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants