diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/SendTransactionScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/SendTransactionScreen.kt index 0aa9cd6f..e504e9ed 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/SendTransactionScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/SendTransactionScreen.kt @@ -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 @@ -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 @@ -82,10 +84,13 @@ 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) } } @@ -93,17 +98,27 @@ fun SendTransactionScreen( 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) { @@ -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) { diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/SendTokensViewModel.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/SendTokensViewModel.kt index a19a90c6..327dec29 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/SendTokensViewModel.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/viewmodel/SendTokensViewModel.kt @@ -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( @@ -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 = emptyList(), val selectedFeeAsset: FeeAsset? = null, val isQuoting: Boolean = false, @@ -86,13 +89,15 @@ class SendTokensViewModel( } } - private val _state = MutableStateFlow(UiState()) + private val _state = MutableStateFlow(UiState(gaslessSupported = gaslessSupported)) val state: StateFlow = _state.asStateFlow() private var relayAddress: TONUserFriendlyAddress? = null - private var jettonMeta: Map = 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() + init { loadTokens() } @@ -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()) } } } @@ -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) { @@ -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() { @@ -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()), ) @@ -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 -> @@ -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) } + } + } + } + } + private fun scheduleQuote() { quoteJob?.cancel() val current = _state.value @@ -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)) } @@ -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") } + } + } + } + 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, ), ) @@ -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 create(modelClass: Class): T = SendTokensViewModel(wallet, kit) as T + override fun create(modelClass: Class): T = SendTokensViewModel(wallet, kit, gaslessSupported) as T } } } diff --git a/Scripts/generate-api/generate-api-models.sh b/Scripts/generate-api/generate-api-models.sh index 6a9b7f22..c1d2a185 100755 --- a/Scripts/generate-api/generate-api-models.sh +++ b/Scripts/generate-api/generate-api-models.sh @@ -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`); 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 diff --git a/Scripts/generate-api/templates/modelGeneric.mustache b/Scripts/generate-api/templates/modelGeneric.mustache index 046aed5f..659821fd 100644 --- a/Scripts/generate-api/templates/modelGeneric.mustache +++ b/Scripts/generate-api/templates/modelGeneric.mustache @@ -3,7 +3,10 @@ Vendor extensions used: - x-generic-params: array of {name} for type parameters - - x-generic-type-ref: string indicating a property uses a generic type parameter + - x-generic-type-ref: string indicating a property uses a generic type parameter directly + - x-generic-instance-type: raw (unprefixed) type for a property whose type is a generic + instantiation parameterized by the parent's type params (e.g. CryptoOnrampQuote); + the template re-attaches the modelNamePrefix -> TONCryptoOnrampQuote - x-frozen: boolean indicating the field should use JsonElement type with private val access Generates Kotlin data classes with generic type parameters and kotlinx.serialization support. @@ -29,6 +32,11 @@ import io.ton.walletkit.model.TONUserFriendlyAddress private val {{{name}}}: {{{dataType}}}{{^required}}? = null{{/required}}{{^-last}},{{/-last}} {{/vendorExtensions.x-frozen}} {{^vendorExtensions.x-frozen}} +{{#vendorExtensions.x-generic-instance-type}} + @SerialName("{{{baseName}}}") + var {{{name}}}: {{modelNamePrefix}}{{{vendorExtensions.x-generic-instance-type}}}{{^required}}? = null{{/required}}{{^-last}},{{/-last}} +{{/vendorExtensions.x-generic-instance-type}} +{{^vendorExtensions.x-generic-instance-type}} {{#vendorExtensions.x-generic-type-ref}} @SerialName("{{{baseName}}}") var {{{name}}}: {{{vendorExtensions.x-generic-type-ref}}}{{^required}}? = null{{/required}}{{^-last}},{{/-last}} @@ -37,6 +45,7 @@ import io.ton.walletkit.model.TONUserFriendlyAddress @SerialName("{{{baseName}}}") var {{{name}}}: {{{dataType}}}{{^required}}? = null{{/required}}{{^-last}},{{/-last}} {{/vendorExtensions.x-generic-type-ref}} +{{/vendorExtensions.x-generic-instance-type}} {{/vendorExtensions.x-frozen}} {{/vars}} ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/ITONWalletKit.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/ITONWalletKit.kt index 612430de..27e4ce4e 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/ITONWalletKit.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/ITONWalletKit.kt @@ -37,6 +37,7 @@ import io.ton.walletkit.config.TONWalletKitConfiguration import io.ton.walletkit.gasless.ITONGaslessManager import io.ton.walletkit.gasless.tonapi.TONApiGaslessProvider import io.ton.walletkit.internal.TONWalletKitFactory +import io.ton.walletkit.jettons.ITONJettonsManager import io.ton.walletkit.listener.TONBridgeEventsHandler import io.ton.walletkit.model.ITONWalletAdapter import io.ton.walletkit.model.KeyPair @@ -214,6 +215,13 @@ interface ITONWalletKit { /** Get the gasless manager for registering providers and relaying gasless transactions. */ suspend fun gasless(): ITONGaslessManager + // ── Jettons ── + + /** + * Get the jettons manager for resolving jetton metadata and a user's jetton holdings. + */ + suspend fun jettons(): ITONJettonsManager + // ── Staking ── /** diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/jettons/ITONJettonsManager.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/jettons/ITONJettonsManager.kt new file mode 100644 index 00000000..35f25923 --- /dev/null +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/jettons/ITONJettonsManager.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.jettons + +import io.ton.walletkit.api.generated.TONJetton +import io.ton.walletkit.api.generated.TONJettonInfo +import io.ton.walletkit.api.generated.TONNetwork +import io.ton.walletkit.model.TONUserFriendlyAddress + +/** + * Manager for resolving jetton-master metadata and a user's jetton holdings. + */ +interface ITONJettonsManager { + /** + * Resolve jetton-master metadata by address. Returns `null` when the master is unknown. + */ + suspend fun jettonInfo( + address: TONUserFriendlyAddress, + network: TONNetwork, + ): TONJettonInfo? + + /** + * All jettons held by a user address, paginated. + */ + suspend fun addressJettons( + userAddress: TONUserFriendlyAddress, + network: TONNetwork, + offset: Int = 0, + limit: Int = 20, + ): List + + /** + * Validate that a string is a well-formed jetton address. + */ + suspend fun validateJettonAddress(address: String): Boolean +} diff --git a/TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs b/TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs index 171ebe70..f83bcc42 100644 --- a/TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs +++ b/TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs @@ -33747,6 +33747,15 @@ var init_SwapProvider = __esmMin((() => { })); //#endregion //#region ../walletkit/dist/esm/defi/errors.js +/** +* Guarantees a typed error: returns `error` unchanged when it already is a {@link DefiError} +* (including subclasses like `SwapError`, `StakingError`, etc.), otherwise wraps it in a +* `DefiError` with the {@link DefiErrorCode.Unknown} code. Use in manager catch blocks so the +* public API always throws a `DefiError`. +*/ +function toDefiError(error, message) { + return error instanceof DefiError ? error : new DefiError(message, DefiErrorCode.Unknown, error); +} var DefiErrorCode, DefiError; var init_errors$4 = __esmMin((() => { (function(DefiErrorCode) { @@ -33756,6 +33765,7 @@ var init_errors$4 = __esmMin((() => { DefiErrorCode["UnsupportedNetwork"] = "UNSUPPORTED_NETWORK"; DefiErrorCode["InvalidParams"] = "INVALID_PARAMS"; DefiErrorCode["InvalidProvider"] = "INVALID_PROVIDER"; + DefiErrorCode["Unknown"] = "UNKNOWN"; })(DefiErrorCode || (DefiErrorCode = {})); DefiError = class extends Error { code; @@ -33769,27 +33779,6 @@ var init_errors$4 = __esmMin((() => { }; })); //#endregion -//#region ../walletkit/dist/esm/defi/swap/errors.js -var SwapErrorCode, SwapError; -var init_errors$3 = __esmMin((() => { - init_errors$4(); - (function(SwapErrorCode) { - SwapErrorCode["InvalidQuote"] = "INVALID_QUOTE"; - SwapErrorCode["InsufficientLiquidity"] = "INSUFFICIENT_LIQUIDITY"; - SwapErrorCode["QuoteExpired"] = "QUOTE_EXPIRED"; - SwapErrorCode["BuildTxFailed"] = "BUILD_TX_FAILED"; - SwapErrorCode["NetworkError"] = "NETWORK_ERROR"; - })(SwapErrorCode || (SwapErrorCode = {})); - SwapError = class extends DefiError { - code; - constructor(message, code, details) { - super(message, code, details); - this.name = "SwapError"; - this.code = code; - } - }; -})); -//#endregion //#region ../walletkit/dist/esm/defi/DefiManager.js var DefiManager; var init_DefiManager = __esmMin((() => { @@ -33814,7 +33803,7 @@ var init_DefiManager = __esmMin((() => { registerProvider(input) { const provider = resolveProvider(input, this.createFactoryContext()); const providerId = provider.providerId; - if (!providerId) throw this.createError("Provider must have a providerId", DefiErrorCode.InvalidProvider); + if (!providerId) throw new DefiError("Provider must have a providerId", DefiErrorCode.InvalidProvider); const oldProvider = this.providers.find((p) => p.providerId === providerId); if (oldProvider) this.removeProvider(oldProvider); this.providers = [...this.providers, provider]; @@ -33846,7 +33835,7 @@ var init_DefiManager = __esmMin((() => { */ setDefaultProvider(providerId) { const provider = this.providers.find((p) => p.providerId === providerId); - if (!provider) throw this.createError(`Provider '${providerId}' not found`, DefiErrorCode.ProviderNotFound, { + if (!provider) throw new DefiError(`Provider '${providerId}' not found`, DefiErrorCode.ProviderNotFound, { provider: providerId, registered: this.providers.map((p) => p.providerId) }); @@ -33864,9 +33853,9 @@ var init_DefiManager = __esmMin((() => { */ getProvider(providerId) { const providerName = providerId || this.defaultProviderId; - if (!providerName) throw this.createError("No default provider set. Register a provider first.", DefiErrorCode.NoDefaultProvider); + if (!providerName) throw new DefiError("No default provider set. Register a provider first.", DefiErrorCode.NoDefaultProvider); const provider = this.providers.find((p) => p.providerId === providerName); - if (!provider) throw this.createError(`Provider '${providerName}' not found`, DefiErrorCode.ProviderNotFound, { + if (!provider) throw new DefiError(`Provider '${providerName}' not found`, DefiErrorCode.ProviderNotFound, { provider: providerName, registered: this.providers.map((p) => p.providerId) }); @@ -33894,9 +33883,9 @@ var init_DefiManager = __esmMin((() => { //#region ../walletkit/dist/esm/defi/swap/SwapManager.js var log$21, SwapManager; var init_SwapManager = __esmMin((() => { - init_errors$3(); init_Logger(); init_DefiManager(); + init_errors$4(); log$21 = globalLogger.createChild("SwapManager"); SwapManager = class extends DefiManager { constructor(createFactoryContext) { @@ -33929,7 +33918,7 @@ var init_SwapManager = __esmMin((() => { error, params }); - throw error; + throw toDefiError(error, "Failed to get swap quote"); } } /** @@ -33952,11 +33941,29 @@ var init_SwapManager = __esmMin((() => { error, params }); - throw error; + throw toDefiError(error, "Failed to build swap transaction"); } } - createError(message, code, details) { - return new SwapError(message, code, details); + }; +})); +//#endregion +//#region ../walletkit/dist/esm/defi/swap/errors.js +var SwapErrorCode, SwapError; +var init_errors$3 = __esmMin((() => { + init_errors$4(); + (function(SwapErrorCode) { + SwapErrorCode["InvalidQuote"] = "INVALID_QUOTE"; + SwapErrorCode["InsufficientLiquidity"] = "INSUFFICIENT_LIQUIDITY"; + SwapErrorCode["QuoteExpired"] = "QUOTE_EXPIRED"; + SwapErrorCode["BuildTxFailed"] = "BUILD_TX_FAILED"; + SwapErrorCode["NetworkError"] = "NETWORK_ERROR"; + })(SwapErrorCode || (SwapErrorCode = {})); + SwapError = class extends DefiError { + code; + constructor(message, code, details) { + super(message, code, details); + this.name = "SwapError"; + this.code = code; } }; })); @@ -33980,30 +33987,12 @@ var init_StakingProvider = __esmMin((() => { }; })); //#endregion -//#region ../walletkit/dist/esm/defi/staking/errors.js -var StakingErrorCode, StakingError; -var init_errors$2 = __esmMin((() => { - init_errors$4(); - (function(StakingErrorCode) { - StakingErrorCode["InvalidParams"] = "INVALID_PARAMS"; - StakingErrorCode["UnsupportedOperation"] = "UNSUPPORTED_OPERATION"; - })(StakingErrorCode || (StakingErrorCode = {})); - StakingError = class extends DefiError { - code; - constructor(message, code, details) { - super(message, code, details); - this.name = "StakingError"; - this.code = code; - } - }; -})); -//#endregion //#region ../walletkit/dist/esm/defi/staking/StakingManager.js var log$20, StakingManager; var init_StakingManager = __esmMin((() => { - init_errors$2(); init_Logger(); init_DefiManager(); + init_errors$4(); log$20 = globalLogger.createChild("StakingManager"); StakingManager = class extends DefiManager { constructor(createFactoryContext) { @@ -34025,7 +34014,7 @@ var init_StakingManager = __esmMin((() => { error, params }); - throw error; + throw toDefiError(error, "Failed to get staking quote"); } } /** @@ -34042,7 +34031,7 @@ var init_StakingManager = __esmMin((() => { error, params }); - throw error; + throw toDefiError(error, "Failed to build staking transaction"); } } /** @@ -34065,7 +34054,7 @@ var init_StakingManager = __esmMin((() => { userAddress, network }); - throw error; + throw toDefiError(error, "Failed to get staking balance"); } } /** @@ -34085,7 +34074,7 @@ var init_StakingManager = __esmMin((() => { error, network }); - throw error; + throw toDefiError(error, "Failed to get staking info"); } } /** @@ -34105,16 +34094,26 @@ var init_StakingManager = __esmMin((() => { error, network }); - throw error; + throw toDefiError(error, "Failed to get staking metadata"); } } - createError(message, code, details) { - const errorCode = Object.values(StakingErrorCode).includes(code) ? code : StakingErrorCode.InvalidParams; - log$20.error(message, { - code, - details - }); - return new StakingError(message, errorCode, details); + }; +})); +//#endregion +//#region ../walletkit/dist/esm/defi/staking/errors.js +var StakingErrorCode, StakingError; +var init_errors$2 = __esmMin((() => { + init_errors$4(); + (function(StakingErrorCode) { + StakingErrorCode["InvalidParams"] = "INVALID_PARAMS"; + StakingErrorCode["UnsupportedOperation"] = "UNSUPPORTED_OPERATION"; + })(StakingErrorCode || (StakingErrorCode = {})); + StakingError = class extends DefiError { + code; + constructor(message, code, details) { + super(message, code, details); + this.name = "StakingError"; + this.code = code; } }; })); @@ -34134,38 +34133,12 @@ var init_GaslessProvider = __esmMin((() => { }; })); //#endregion -//#region ../walletkit/dist/esm/defi/gasless/errors.js -var GaslessErrorCode, GaslessError; -var init_errors$1 = __esmMin((() => { - init_errors$4(); - (function(GaslessErrorCode) { - GaslessErrorCode["UnsupportedFeeAsset"] = "UNSUPPORTED_FEE_ASSET"; - GaslessErrorCode["UnsupportedOperation"] = "UNSUPPORTED_OPERATION"; - GaslessErrorCode["QuoteFailed"] = "QUOTE_FAILED"; - GaslessErrorCode["SendFailed"] = "SEND_FAILED"; - GaslessErrorCode["ConfigFailed"] = "CONFIG_FAILED"; - GaslessErrorCode["SignMessageNotSupported"] = "SIGN_MESSAGE_NOT_SUPPORTED"; - GaslessErrorCode["TooManyMessages"] = "TOO_MANY_MESSAGES"; - GaslessErrorCode["QuoteExpired"] = "QUOTE_EXPIRED"; - GaslessErrorCode["WalletMismatch"] = "WALLET_MISMATCH"; - GaslessErrorCode["FeeAssetNotOwned"] = "FEE_ASSET_NOT_OWNED"; - })(GaslessErrorCode || (GaslessErrorCode = {})); - GaslessError = class extends DefiError { - code; - constructor(message, code, details) { - super(message, code, details); - this.name = "GaslessError"; - this.code = code; - } - }; -})); -//#endregion //#region ../walletkit/dist/esm/defi/gasless/GaslessManager.js var log$19, GaslessManager; var init_GaslessManager = __esmMin((() => { init_Logger(); init_DefiManager(); - init_errors$1(); + init_errors$4(); log$19 = globalLogger.createChild("GaslessManager"); GaslessManager = class extends DefiManager { constructor(createFactoryContext) { @@ -34181,7 +34154,7 @@ var init_GaslessManager = __esmMin((() => { return await this.getProvider(selectedProviderId).getMetadata(); } catch (error) { log$19.error("Failed to get gasless provider metadata", { error }); - throw error; + throw toDefiError(error, "Failed to get gasless provider metadata"); } } /** @@ -34200,7 +34173,7 @@ var init_GaslessManager = __esmMin((() => { return await provider.getConfig(targetNetwork); } catch (error) { log$19.error("Failed to get gasless config", { error }); - throw error; + throw toDefiError(error, "Failed to get gasless config"); } } /** @@ -34221,7 +34194,7 @@ var init_GaslessManager = __esmMin((() => { error, params }); - throw error; + throw toDefiError(error, "Failed to quote gasless transaction"); } } /** @@ -34236,11 +34209,34 @@ var init_GaslessManager = __esmMin((() => { return await this.getProvider(providerId ?? this.defaultProviderId).sendTransaction(params); } catch (error) { log$19.error("Failed to send gasless transaction", { error }); - throw error; + throw toDefiError(error, "Failed to send gasless transaction"); } } - createError(message, code, details) { - return new GaslessError(message, code, details); + }; +})); +//#endregion +//#region ../walletkit/dist/esm/defi/gasless/errors.js +var GaslessErrorCode, GaslessError; +var init_errors$1 = __esmMin((() => { + init_errors$4(); + (function(GaslessErrorCode) { + GaslessErrorCode["UnsupportedFeeAsset"] = "UNSUPPORTED_FEE_ASSET"; + GaslessErrorCode["UnsupportedOperation"] = "UNSUPPORTED_OPERATION"; + GaslessErrorCode["QuoteFailed"] = "QUOTE_FAILED"; + GaslessErrorCode["SendFailed"] = "SEND_FAILED"; + GaslessErrorCode["ConfigFailed"] = "CONFIG_FAILED"; + GaslessErrorCode["SignMessageNotSupported"] = "SIGN_MESSAGE_NOT_SUPPORTED"; + GaslessErrorCode["TooManyMessages"] = "TOO_MANY_MESSAGES"; + GaslessErrorCode["QuoteExpired"] = "QUOTE_EXPIRED"; + GaslessErrorCode["WalletMismatch"] = "WALLET_MISMATCH"; + GaslessErrorCode["FeeAssetNotOwned"] = "FEE_ASSET_NOT_OWNED"; + })(GaslessErrorCode || (GaslessErrorCode = {})); + GaslessError = class extends DefiError { + code; + constructor(message, code, details) { + super(message, code, details); + this.name = "GaslessError"; + this.code = code; } }; })); @@ -36686,6 +36682,7 @@ var init_errors = __esmMin((() => { (function(CryptoOnrampErrorCode) { CryptoOnrampErrorCode["ProviderError"] = "PROVIDER_ERROR"; CryptoOnrampErrorCode["QuoteFailed"] = "QUOTE_FAILED"; + CryptoOnrampErrorCode["DepositFailed"] = "DEPOSIT_FAILED"; CryptoOnrampErrorCode["RefundAddressRequired"] = "REFUND_ADDRESS_REQUIRED"; CryptoOnrampErrorCode["InvalidRefundAddress"] = "INVALID_REFUND_ADDRESS"; CryptoOnrampErrorCode["ReversedAmountNotSupported"] = "REVERSED_AMOUNT_NOT_SUPPORTED"; @@ -36710,7 +36707,7 @@ var init_errors = __esmMin((() => { //#region ../walletkit/dist/esm/defi/crypto-onramp/CryptoOnrampManager.js var log$11, CryptoOnrampManager; var init_CryptoOnrampManager = __esmMin((() => { - init_errors(); + init_errors$4(); init_Logger(); init_DefiManager(); log$11 = globalLogger.createChild("CryptoOnrampManager"); @@ -36726,7 +36723,7 @@ var init_CryptoOnrampManager = __esmMin((() => { return this.getProvider(selectedProviderId).getMetadata(); } catch (error) { log$11.error("Failed to get crypto onramp metadata", { error }); - throw error; + throw toDefiError(error, "Failed to get crypto onramp metadata"); } } /** @@ -36758,7 +36755,7 @@ var init_CryptoOnrampManager = __esmMin((() => { error, params }); - throw error; + throw toDefiError(error, "Failed to get crypto onramp quote"); } } /** @@ -36787,7 +36784,7 @@ var init_CryptoOnrampManager = __esmMin((() => { error, params }); - throw error; + throw toDefiError(error, "Failed to create crypto onramp deposit"); } } /** @@ -36811,7 +36808,7 @@ var init_CryptoOnrampManager = __esmMin((() => { error, params }); - throw error; + throw toDefiError(error, "Failed to get crypto onramp deposit status"); } } /** @@ -36825,12 +36822,9 @@ var init_CryptoOnrampManager = __esmMin((() => { return await this.getProvider(selectedProviderId).getSupportedCurrencies(); } catch (error) { log$11.error("Failed to discover crypto onramp supported currencies", { error }); - throw error; + throw toDefiError(error, "Failed to discover crypto onramp supported currencies"); } } - createError(message, code, details) { - return new CryptoOnrampError(message, code, details); - } }; })); //#endregion @@ -41057,6 +41051,7 @@ init_models(); init_Logger(); init_StakingProvider(); init_errors$2(); +init_errors$4(); init_ApiClientTonApi(); init_units(); var log$3 = globalLogger.createChild("TonStakersStakingProvider"); @@ -41121,7 +41116,7 @@ var TonStakersStakingProvider = class TonStakersStakingProvider extends StakingP if (!hasDefaultContract && !hasCustomContract) continue; chainConfig[chainId] = perChain; } - if (Object.keys(chainConfig).length === 0) throw new Error("createTonstakersProvider: no eligible networks (add mainnet/testnet or pass metadata.contractAddress in overrides)"); + if (Object.keys(chainConfig).length === 0) throw new DefiError("createTonstakersProvider: no eligible networks (add mainnet/testnet or pass metadata.contractAddress in overrides)", DefiErrorCode.InvalidParams); return new TonStakersStakingProvider(ctx.networkManager, chainConfig); } /** @@ -41380,7 +41375,7 @@ var TonStakersStakingProvider = class TonStakersStakingProvider extends StakingP network, apiKey: token }).getJson(`/v2/staking/pool/${address}`); - if (!poolInfo?.pool?.apy) throw new Error("Invalid APY data from TonAPI"); + if (!poolInfo?.pool?.apy) throw new StakingError("Invalid APY data from TonAPI", StakingErrorCode.InvalidParams); return Number(poolInfo.pool.apy); } static isValidTokenInfo(token) { @@ -46229,6 +46224,7 @@ init_Logger(); init_retry(); init_errors$1(); init_GaslessProvider(); +init_errors$4(); var log = globalLogger.createChild("TonApiGaslessProvider"); /** * Gasless provider implementation backed by the public TonAPI REST API. @@ -46299,7 +46295,7 @@ var TonApiGaslessProvider = class TonApiGaslessProvider extends GaslessProvider chainConfig[chainId] = perChain; } else for (const chainId of configuredChains) chainConfig[chainId] = {}; - if (Object.keys(chainConfig).length === 0) throw new Error("createTonApiGaslessProvider: no eligible networks (configure at least one network in the kit, or pass `chains` matching a configured network)"); + if (Object.keys(chainConfig).length === 0) throw new DefiError("createTonApiGaslessProvider: no eligible networks (configure at least one network in the kit, or pass `chains` matching a configured network)", DefiErrorCode.InvalidParams); return new TonApiGaslessProvider(chainConfig, config); } getSupportedNetworks() { diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/core/TONWalletKit.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/core/TONWalletKit.kt index 4dfdcc3c..bef7f5e2 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/core/TONWalletKit.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/core/TONWalletKit.kt @@ -50,6 +50,8 @@ import io.ton.walletkit.gasless.tonapi.TONApiGaslessProvider import io.ton.walletkit.gasless.tonapi.TONApiGaslessProviderIdentifier import io.ton.walletkit.internal.constants.BridgeMethodConstants import io.ton.walletkit.internal.util.WalletKitUtils +import io.ton.walletkit.jettons.ITONJettonsManager +import io.ton.walletkit.jettons.TONJettonsManager import io.ton.walletkit.listener.TONBridgeEventsHandler import io.ton.walletkit.model.ITONWalletAdapter import io.ton.walletkit.model.KeyPair @@ -132,6 +134,8 @@ internal class TONWalletKit private constructor( private val gaslessManager: ITONGaslessManager = TONGaslessManager(engine) + private val jettonsManager: ITONJettonsManager = TONJettonsManager(engine) + companion object { /** * Initialize TON Wallet Kit with configuration. @@ -492,6 +496,8 @@ internal class TONWalletKit private constructor( override suspend fun gasless(): ITONGaslessManager = gaslessManager + override suspend fun jettons(): ITONJettonsManager = jettonsManager + override suspend fun staking(): ITONStakingManager { checkNotDestroyed() return _stakingManager diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/WalletKitEngine.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/WalletKitEngine.kt index 1641cb6b..9aea623f 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/WalletKitEngine.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/WalletKitEngine.kt @@ -34,6 +34,8 @@ import io.ton.walletkit.api.generated.TONGaslessQuoteParams import io.ton.walletkit.api.generated.TONGaslessSendParams import io.ton.walletkit.api.generated.TONGaslessSendResponse import io.ton.walletkit.api.generated.TONGetMethodResult +import io.ton.walletkit.api.generated.TONJetton +import io.ton.walletkit.api.generated.TONJettonInfo import io.ton.walletkit.api.generated.TONJettonsResponse import io.ton.walletkit.api.generated.TONJettonsTransferRequest import io.ton.walletkit.api.generated.TONMasterchainInfo @@ -644,6 +646,22 @@ internal interface WalletKitEngine : RequestHandler { suspend fun gaslessSendTransaction(params: TONGaslessSendParams, providerId: String?): TONGaslessSendResponse + // ── Jettons (kit-level manager) ── + + /** Resolve jetton-master metadata by address. Returns null when the master is unknown. */ + suspend fun jettonInfo(address: String, network: TONNetwork): TONJettonInfo? + + /** List the jettons held by a user address, paginated. */ + suspend fun addressJettons( + userAddress: String, + network: TONNetwork, + offset: Int, + limit: Int, + ): List + + /** Validate that a string is a well-formed jetton address. */ + suspend fun validateJettonAddress(address: String): Boolean + // ── Staking ── /** diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/WebViewWalletKitEngine.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/WebViewWalletKitEngine.kt index 92536f73..61734642 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/WebViewWalletKitEngine.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/WebViewWalletKitEngine.kt @@ -32,6 +32,8 @@ import io.ton.walletkit.api.generated.TONEmulationResult import io.ton.walletkit.api.generated.TONGaslessQuoteParams import io.ton.walletkit.api.generated.TONGaslessSendParams import io.ton.walletkit.api.generated.TONGetMethodResult +import io.ton.walletkit.api.generated.TONJetton +import io.ton.walletkit.api.generated.TONJettonInfo import io.ton.walletkit.api.generated.TONJettonsResponse import io.ton.walletkit.api.generated.TONJettonsTransferRequest import io.ton.walletkit.api.generated.TONMasterchainInfo @@ -103,12 +105,14 @@ import io.ton.walletkit.engine.operations.createTransferTonTransaction import io.ton.walletkit.engine.operations.createWalletAdapter import io.ton.walletkit.engine.operations.disconnectSession import io.ton.walletkit.engine.operations.gaslessSendTransaction +import io.ton.walletkit.engine.operations.getAddressJettons import io.ton.walletkit.engine.operations.getBalance import io.ton.walletkit.engine.operations.getGaslessConfig import io.ton.walletkit.engine.operations.getGaslessMetadata import io.ton.walletkit.engine.operations.getGaslessProviderSupportedNetworks import io.ton.walletkit.engine.operations.getGaslessQuote import io.ton.walletkit.engine.operations.getJettonBalance +import io.ton.walletkit.engine.operations.getJettonInfo import io.ton.walletkit.engine.operations.getJettonWalletAddress import io.ton.walletkit.engine.operations.getJettons import io.ton.walletkit.engine.operations.getNft @@ -161,6 +165,7 @@ import io.ton.walletkit.engine.operations.setDefaultGaslessProvider import io.ton.walletkit.engine.operations.setDefaultStakingProvider import io.ton.walletkit.engine.operations.setDefaultSwapProvider import io.ton.walletkit.engine.operations.sign +import io.ton.walletkit.engine.operations.validateJettonAddress import io.ton.walletkit.engine.operations.walletClientAccountState import io.ton.walletkit.engine.operations.walletClientAccountStates import io.ton.walletkit.engine.operations.walletClientBackResolveDnsWallet @@ -678,6 +683,19 @@ internal class WebViewWalletKitEngine private constructor( override suspend fun gaslessSendTransaction(params: TONGaslessSendParams, providerId: String?) = rpcClient.gaslessSendTransaction(params, providerId) + override suspend fun jettonInfo(address: String, network: TONNetwork): TONJettonInfo? = + rpcClient.getJettonInfo(address, network) + + override suspend fun addressJettons( + userAddress: String, + network: TONNetwork, + offset: Int, + limit: Int, + ): List = rpcClient.getAddressJettons(userAddress, network, offset, limit) + + override suspend fun validateJettonAddress(address: String): Boolean = + rpcClient.validateJettonAddress(address) + override suspend fun createTonStakersStakingProvider(chainConfig: Map?): String = rpcClient.createTonStakersStakingProvider(chainConfig) diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/JettonsOperations.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/JettonsOperations.kt new file mode 100644 index 00000000..b56c2646 --- /dev/null +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/JettonsOperations.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.engine.operations + +import io.ton.walletkit.api.generated.TONJetton +import io.ton.walletkit.api.generated.TONJettonInfo +import io.ton.walletkit.api.generated.TONNetwork +import io.ton.walletkit.engine.infrastructure.BridgeRpcClient +import io.ton.walletkit.engine.infrastructure.callTyped +import io.ton.walletkit.engine.infrastructure.callTypedOrNull +import io.ton.walletkit.engine.operations.requests.GetAddressJettonsRequest +import io.ton.walletkit.engine.operations.requests.GetJettonInfoRequest +import io.ton.walletkit.engine.operations.requests.ValidateJettonAddressRequest +import io.ton.walletkit.engine.operations.responses.ValidateJettonAddressResponse +import io.ton.walletkit.internal.constants.BridgeMethodConstants + +internal suspend fun BridgeRpcClient.getJettonInfo(address: String, network: TONNetwork): TONJettonInfo? = + callTypedOrNull( + BridgeMethodConstants.METHOD_GET_JETTON_INFO, + GetJettonInfoRequest(address = address, network = network), + ) + +internal suspend fun BridgeRpcClient.getAddressJettons( + userAddress: String, + network: TONNetwork, + offset: Int, + limit: Int, +): List = callTyped( + BridgeMethodConstants.METHOD_GET_ADDRESS_JETTONS, + GetAddressJettonsRequest(userAddress = userAddress, network = network, offset = offset, limit = limit), +) + +internal suspend fun BridgeRpcClient.validateJettonAddress(address: String): Boolean = + callTyped( + BridgeMethodConstants.METHOD_VALIDATE_JETTON_ADDRESS, + ValidateJettonAddressRequest(address = address), + ).valid diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/requests/JettonsRequests.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/requests/JettonsRequests.kt new file mode 100644 index 00000000..3eaaa3b6 --- /dev/null +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/requests/JettonsRequests.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.engine.operations.requests + +import io.ton.walletkit.api.generated.TONNetwork +import kotlinx.serialization.Serializable + +/** + * Internal bridge request models for the kit-level jettons manager. + * + * @suppress Internal bridge communication only. + */ + +@Serializable +internal data class GetJettonInfoRequest( + val address: String, + val network: TONNetwork, +) + +@Serializable +internal data class GetAddressJettonsRequest( + val userAddress: String, + val network: TONNetwork, + val offset: Int, + val limit: Int, +) + +@Serializable +internal data class ValidateJettonAddressRequest( + val address: String, +) diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/BridgeResponses.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/BridgeResponses.kt index 1cc9eca0..5bbf939b 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/BridgeResponses.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/engine/operations/responses/BridgeResponses.kt @@ -97,6 +97,9 @@ internal data class ProviderIdsResponse(val providerIds: List = emptyLis @Serializable internal data class HasProviderResponse(val result: Boolean = false) +@Serializable +internal data class ValidateJettonAddressResponse(val valid: Boolean = false) + @Serializable internal data class SupportedNetworksResponse( val networks: List = emptyList(), diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/internal/constants/BridgeMethodConstants.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/internal/constants/BridgeMethodConstants.kt index 4f350400..e1adce8c 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/internal/constants/BridgeMethodConstants.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/internal/constants/BridgeMethodConstants.kt @@ -268,6 +268,21 @@ internal object BridgeMethodConstants { */ const val METHOD_GET_JETTON_WALLET_ADDRESS = "getJettonWalletAddress" + /** + * Method name for resolving jetton-master metadata by address (kit-level jettons manager). + */ + const val METHOD_GET_JETTON_INFO = "getJettonInfo" + + /** + * Method name for listing the jettons held by a user address (kit-level jettons manager). + */ + const val METHOD_GET_ADDRESS_JETTONS = "getAddressJettons" + + /** + * Method name for validating a jetton address (kit-level jettons manager). + */ + const val METHOD_VALIDATE_JETTON_ADDRESS = "validateJettonAddress" + // Wallet API client methods (per-wallet TONAPIClient bridge). /** Send a signed BOC via the wallet's API client. */ diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/jettons/TONJettonsManager.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/jettons/TONJettonsManager.kt new file mode 100644 index 00000000..bec05748 --- /dev/null +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/jettons/TONJettonsManager.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 TonTech + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package io.ton.walletkit.jettons + +import io.ton.walletkit.api.generated.TONJetton +import io.ton.walletkit.api.generated.TONJettonInfo +import io.ton.walletkit.api.generated.TONNetwork +import io.ton.walletkit.engine.WalletKitEngine +import io.ton.walletkit.model.TONUserFriendlyAddress + +internal class TONJettonsManager( + private val engine: WalletKitEngine, +) : ITONJettonsManager { + + override suspend fun jettonInfo( + address: TONUserFriendlyAddress, + network: TONNetwork, + ): TONJettonInfo? = engine.jettonInfo(address.value, network) + + override suspend fun addressJettons( + userAddress: TONUserFriendlyAddress, + network: TONNetwork, + offset: Int, + limit: Int, + ): List = engine.addressJettons(userAddress.value, network, offset, limit) + + override suspend fun validateJettonAddress(address: String): Boolean = + engine.validateJettonAddress(address) +}