Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
Expand All @@ -51,6 +52,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import io.ton.walletkit.ITONWallet
import io.ton.walletkit.ITONWalletKit
import io.ton.walletkit.api.WalletVersions
import io.ton.walletkit.demo.designsystem.components.button.TonButton
import io.ton.walletkit.demo.designsystem.components.text.TonText
import io.ton.walletkit.demo.designsystem.components.toggle.TonSwitch
Expand Down Expand Up @@ -82,28 +84,41 @@ fun SendTransactionScreen(
resolving = false
}

// tonapi's gasless relay only supports W5 (v5r1) wallets, so gasless is offered only for those.
val gaslessSupported = wallet.version.equals(WalletVersions.V5R1, ignoreCase = true)

when {
resolving -> SendStatusBox("Loading wallet…")
tonWallet == null -> SendStatusBox("Could not resolve the active wallet.", onBack)
else -> SendTokensContent(tonWallet!!, walletKit, onBack)
else -> SendTokensContent(tonWallet!!, walletKit, gaslessSupported, onBack)
}
}

@Composable
private fun SendTokensContent(
wallet: ITONWallet,
walletKit: ITONWalletKit,
gaslessSupported: Boolean,
onBack: () -> Unit,
) {
val viewModel: SendTokensViewModel = viewModel(
key = "send:${wallet.address().value}",
factory = SendTokensViewModel.factory(wallet, walletKit),
factory = SendTokensViewModel.factory(wallet, walletKit, gaslessSupported),
)
val state by viewModel.state.collectAsState()
var picker by remember { mutableStateOf(PickerMode.None) }

LaunchedEffect(state.sent) {
if (state.sent) onBack()
if (state.sent) {
viewModel.acknowledgeSent()
onBack()
}
}

// Resolve fee-asset metadata (ticker / icon / decimals) once the fee list appears, so the picker
// rows and the inline selected-asset label fill in from short addresses to tickers as they load.
LaunchedEffect(state.feeAssets.size) {
if (state.feeAssets.isNotEmpty()) viewModel.loadFeeAssetMetadata()
}

when (picker) {
Expand Down Expand Up @@ -255,12 +270,16 @@ private fun GaslessCard(
Column(modifier = Modifier.weight(1f)) {
TonText("Gasless", style = TonTheme.typography.bodySemibold, color = TonTheme.colors.textPrimary)
TonText(
"Pay the network fee in a jetton",
if (state.gaslessSupported) "Pay the network fee in a jetton" else "Requires a W5 (v5r1) wallet",
style = TonTheme.typography.subheadline2,
color = TonTheme.colors.textSecondary,
)
}
TonSwitch(checked = state.gaslessEnabled, onCheckedChange = viewModel::setGaslessEnabled)
if (state.gaslessSupported) {
TonSwitch(checked = state.gaslessEnabled, onCheckedChange = viewModel::setGaslessEnabled)
} else {
TonSwitch(checked = false, onCheckedChange = {}, modifier = Modifier.alpha(0.4f))
}
}

if (state.gaslessEnabled) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,16 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.BigInteger

/**
* Drives the "send any token" sheet — pick TON or any held jetton, optionally pay the
* network fee gaslessly in a jetton. Mirrors the iOS `SendTokensViewModel`.
* network fee gaslessly in a jetton.
*/
class SendTokensViewModel(
private val wallet: ITONWallet,
private val kit: ITONWalletKit,
private val gaslessSupported: Boolean,
) : ViewModel() {

data class UiState(
Expand All @@ -65,6 +67,7 @@ class SendTokensViewModel(
val sent: Boolean = false,
val error: String? = null,
val gaslessEnabled: Boolean = false,
val gaslessSupported: Boolean = false,
val feeAssets: List<FeeAsset> = emptyList(),
val selectedFeeAsset: FeeAsset? = null,
val isQuoting: Boolean = false,
Expand All @@ -86,13 +89,15 @@ class SendTokensViewModel(
}
}

private val _state = MutableStateFlow(UiState())
private val _state = MutableStateFlow(UiState(gaslessSupported = gaslessSupported))
val state: StateFlow<UiState> = _state.asStateFlow()

private var relayAddress: TONUserFriendlyAddress? = null
private var jettonMeta: Map<String, FeeAsset> = emptyMap()
private var quoteJob: Job? = null

/** Resolved fee-asset metadata keyed by master address, so re-opening the picker reuses it. */
private val resolvedFeeAssets = mutableMapOf<String, FeeAsset>()

init {
loadTokens()
}
Expand Down Expand Up @@ -130,10 +135,6 @@ class SendTokensViewModel(
}
}.onFailure { Log.e(TAG, "Failed to load jettons", it) }

jettonMeta = tokens.filter { !it.isNativeTON }.associate { token ->
token.masterAddress!! to FeeAsset(token.masterAddress, token.symbol, token.decimals, token.imageSource)
}

_state.update { it.copy(tokens = tokens, selectedToken = it.selectedToken ?: tokens.firstOrNull()) }
}
}
Expand Down Expand Up @@ -167,13 +168,25 @@ class SendTokensViewModel(
}

fun useMax() {
_state.update { it.copy(amount = it.selectedToken?.displayBalance ?: "") }
val current = _state.value
val token = current.selectedToken ?: return
val feeAsset = current.selectedFeeAsset
val relay = relayAddress
if (current.gaslessEnabled && current.canUseGasless && relay != null &&
feeAsset != null && feeAsset.address == token.masterAddress
) {
computeGaslessMax(token, feeAsset, relay)
return
}
_state.update { it.copy(amount = token.displayBalance) }
scheduleQuote()
}

fun setGaslessEnabled(enabled: Boolean) {
_state.update { it.copy(gaslessEnabled = enabled, gaslessError = null, gaslessFeeText = null) }
if (enabled) loadGaslessConfig() else quoteJob?.cancel()
// tonapi's gasless relay only supports W5 (v5r1) wallets; never enable it on others.
val on = enabled && gaslessSupported
_state.update { it.copy(gaslessEnabled = on, gaslessError = null, gaslessFeeText = null) }
if (on) loadGaslessConfig() else quoteJob?.cancel()
}

fun selectFeeAsset(asset: FeeAsset) {
Expand All @@ -185,6 +198,15 @@ class SendTokensViewModel(
_state.update { it.copy(error = null) }
}

/**
* Clear the one-shot "sent" flag after the UI has navigated away. The view model is retained per
* wallet (the send sheet shares the wallet screen's store owner), so without this a stale `sent`
* from a previous successful send would immediately dismiss the sheet the next time it opens.
*/
fun acknowledgeSent() {
_state.update { it.copy(sent = false) }
}

// MARK: - Sending

fun send() {
Expand Down Expand Up @@ -224,7 +246,7 @@ class SendTokensViewModel(
val feeAsset = current.selectedFeeAsset ?: error("Fee asset not selected")
val relay = relayAddress ?: error("Gasless config not loaded")
val gasless = ensureGasless()
val quote = quote(gasless, token, feeAsset, current, relay)
val quote = quote(gasless, token, feeAsset, current.recipient, toRaw(current.amount, token.decimals), relay)
val internalBoc = wallet.signedSignMessage(
TONTransactionRequest(messages = quote.messages, validUntil = quote.validUntil, network = wallet.network()),
)
Expand All @@ -246,7 +268,7 @@ class SendTokensViewModel(
val config = gasless.getConfig(network = wallet.network())
relayAddress = config.relayAddress
val assets = config.supportedAssets.map { supported ->
jettonMeta[supported.address.value]
resolvedFeeAssets[supported.address.value]
?: FeeAsset(supported.address.value, shortAddress(supported.address.value), TON_DECIMALS, null)
}
_state.update { state ->
Expand All @@ -263,6 +285,42 @@ class SendTokensViewModel(
}
}

/**
* 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) }
}
}
}
}

Comment on lines +288 to +323

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.

private fun scheduleQuote() {
quoteJob?.cancel()
val current = _state.value
Expand All @@ -280,7 +338,14 @@ class SendTokensViewModel(
_state.update { it.copy(isQuoting = true, gaslessError = null) }
runCatching {
val gasless = ensureGasless()
val quote = quote(gasless, token, current.selectedFeeAsset, current, relayAddress!!)
val quote = quote(
gasless,
token,
current.selectedFeeAsset,
current.recipient,
toRaw(current.amount, token.decimals),
relayAddress!!,
)
_state.update {
it.copy(isQuoting = false, gaslessFeeText = formatFee(quote.fee, current.selectedFeeAsset))
}
Expand All @@ -293,18 +358,59 @@ class SendTokensViewModel(
}
}

/**
* 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") }
}
}
}
Comment on lines +361 to +399

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.


private suspend fun quote(
gasless: ITONGaslessManager,
token: SendableToken,
feeAsset: FeeAsset,
current: UiState,
recipient: String,
transferAmountRaw: String,
relay: TONUserFriendlyAddress,
): TONGaslessQuote {
val transfer = wallet.transferJettonTransaction(
TONJettonsTransferRequest(
jettonAddress = TONUserFriendlyAddress(token.masterAddress!!),
transferAmount = toRaw(current.amount, token.decimals),
recipientAddress = TONUserFriendlyAddress(current.recipient.trim()),
transferAmount = transferAmountRaw,
recipientAddress = TONUserFriendlyAddress(recipient.trim()),
responseDestination = relay,
),
)
Expand Down Expand Up @@ -358,12 +464,15 @@ class SendTokensViewModel(
private const val TON_DECIMALS = 9
private const val QUOTE_DEBOUNCE_MS = 400L

/** Mainnet USDT jetton master — preferred default gasless fee asset, mirroring the iOS/JS demo. */
private const val USDT_MASTER_MAINNET = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"

fun factory(wallet: ITONWallet, kit: ITONWalletKit): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
fun factory(
wallet: ITONWallet,
kit: ITONWalletKit,
gaslessSupported: Boolean,
): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = SendTokensViewModel(wallet, kit) as T
override fun <T : ViewModel> create(modelClass: Class<T>): T = SendTokensViewModel(wallet, kit, gaslessSupported) as T
}
}
}
48 changes: 0 additions & 48 deletions Scripts/generate-api/generate-api-models.sh
Original file line number Diff line number Diff line change
Expand Up @@ -225,54 +225,6 @@ def normalize_screaming_snake_type_names():

normalize_screaming_snake_type_names()

# Backfill generic property type references the upstream spec generator omits. A property whose
# `$ref` targets a generic model (one carrying `x-generic-params`) must itself carry
# `x-generic-type-ref` so the Kotlin template emits the type argument
# (e.g. `quote: TONCryptoOnrampQuote<TQuoteMetadata>`); without it the property renders as the bare
# generic class and fails to compile. The owning class's type params not already claimed by a
# sibling `x-generic-type-ref` are assigned in declaration order. The "TON" prefix mirrors
# modelNamePrefix in generate-api-models-config.json. Mutates the rewritten `data` (see
# add_inheritance). No-op once upstream emits the extension directly.
def backfill_generic_type_refs():
out_schemas = data.get("components", {}).get("schemas", {}) or {}
arity = {
name: len(sc["x-generic-params"])
for name, sc in out_schemas.items()
if isinstance(sc, dict) and sc.get("x-generic-params")
}
if not arity:
return
for sc in out_schemas.values():
if not isinstance(sc, dict):
continue
params = [p.get("name") for p in (sc.get("x-generic-params") or []) if isinstance(p, dict)]
if not params:
continue
props = sc.get("properties") or {}
claimed = {
pd["x-generic-type-ref"]
for pd in props.values()
if isinstance(pd, dict) and isinstance(pd.get("x-generic-type-ref"), str)
}
free = [p for p in params if p not in claimed]
for pd in props.values():
if not isinstance(pd, dict) or "x-generic-type-ref" in pd:
continue
ref = pd.get("$ref")
target = ref.rsplit("/", 1)[-1] if isinstance(ref, str) else None
if target not in arity:
continue
args = free[:arity[target]]
if len(args) != arity[target]:
continue
del free[:len(args)]
# OpenAPI 3.0 ignores keywords sibling to `$ref`, so move the ref under `allOf` to keep
# the extension live while still resolving the referenced model.
pd["allOf"] = [{"$ref": pd.pop("$ref")}]
pd["x-generic-type-ref"] = "TON%s<%s>" % (target, ", ".join(args))

backfill_generic_type_refs()

with open(target, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
PY
Expand Down
Loading
Loading