SELF-2650: Add WebView Ethereum bridge PoC with common bridge, Android/iOS implementations, and UI (#1989)

* Add WebView ethereum bridge PoC to kmp minipay sample

* fix pipelines

* pr feedback

* fix
This commit is contained in:
Justin Hernandez
2026-04-17 22:04:05 -07:00
committed by GitHub
parent b51ea3019d
commit dee6eba5ff
6 changed files with 572 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.minipay.screens
import android.annotation.SuppressLint
import android.content.Context
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.serialization.json.Json
import org.json.JSONObject
import xyz.self.minipay.webview.BRIDGE_DEMO_HTML
import xyz.self.minipay.webview.BridgeMethodException
import xyz.self.minipay.webview.ETHEREUM_BRIDGE_CHANNEL
import xyz.self.minipay.webview.ETHEREUM_BRIDGE_STUB
import xyz.self.minipay.webview.MethodRegistry
import xyz.self.minipay.webview.ProviderError
import xyz.self.minipay.webview.ProviderErrorCodes
import xyz.self.minipay.webview.ProviderRequest
import xyz.self.minipay.webview.ProviderResponse
private val json = Json { ignoreUnknownKeys = true }
@Composable
actual fun PlatformWebViewBridge(registry: MethodRegistry) {
AndroidView(
modifier = Modifier,
factory = { context -> createWebView(context = context, registry = registry) },
)
}
@SuppressLint("SetJavaScriptEnabled")
private fun createWebView(
context: Context,
registry: MethodRegistry,
): WebView {
val bridge = AndroidEthereumBridge(registry)
return WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webViewClient =
object : WebViewClient() {
override fun onPageFinished(
view: WebView,
url: String?,
) {
view.evaluateJavascript(ETHEREUM_BRIDGE_STUB, null)
}
}
addJavascriptInterface(bridge, ETHEREUM_BRIDGE_CHANNEL)
bridge.attach(this)
loadDataWithBaseURL("https://localhost/", BRIDGE_DEMO_HTML, "text/html", "utf-8", null)
}
}
private class AndroidEthereumBridge(
private val registry: MethodRegistry,
) {
private val pendingRequests = mutableMapOf<String, (ProviderResponse) -> Unit>()
private var webView: WebView? = null
fun attach(webView: WebView) {
this.webView = webView
}
@JavascriptInterface
fun postMessage(requestJson: String) {
val request =
try {
json.decodeFromString<ProviderRequest>(requestJson)
} catch (_: Exception) {
val fallbackId = requestJson.substringAfter("\"id\":\"", "").substringBefore("\"", "")
if (fallbackId.isNotEmpty()) {
respond(
ProviderResponse(
id = fallbackId,
error =
ProviderError(
code = ProviderErrorCodes.INVALID_PARAMS,
message = "Invalid request payload",
),
),
)
}
return
}
pendingRequests[request.id] = { response -> sendResponseToJs(response) }
val response =
try {
registry.dispatch(request)
} catch (exception: BridgeMethodException) {
ProviderResponse(id = request.id, error = exception.providerError)
} catch (exception: Exception) {
ProviderResponse(
id = request.id,
error =
ProviderError(
code = ProviderErrorCodes.INTERNAL_ERROR,
message = exception.message ?: "Internal error",
),
)
}
respond(response)
}
private fun respond(response: ProviderResponse) {
val callback = pendingRequests.remove(response.id) ?: return
callback(response)
}
private fun sendResponseToJs(response: ProviderResponse) {
val payload = json.encodeToString(ProviderResponse.serializer(), response)
val escaped = JSONObject.quote(payload)
webView?.post {
webView?.evaluateJavascript("window.__selfEthereumResolve($escaped);", null)
}
}
}

View File

@@ -11,6 +11,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import xyz.self.minipay.screens.HomeScreen
import xyz.self.minipay.screens.ResultScreen
import xyz.self.minipay.screens.WebViewBridgeScreen
import xyz.self.minipay.theme.MiniPayTheme
import xyz.self.sdk.api.SelfSdk
@@ -31,6 +32,7 @@ fun App(sdk: SelfSdk? = null, platformContext: Any? = null) {
viewModel = viewModel,
onVerify = { viewModel.launchVerification(platformContext) },
onNavigateToResult = { navController.navigate("result") },
onOpenWebViewBridge = { navController.navigate("webview-bridge") },
)
}
@@ -47,6 +49,10 @@ fun App(sdk: SelfSdk? = null, platformContext: Any? = null) {
},
)
}
composable("webview-bridge") {
WebViewBridgeScreen()
}
}
}
}

View File

@@ -31,6 +31,7 @@ fun HomeScreen(
viewModel: MainViewModel,
onVerify: () -> Unit,
onNavigateToResult: () -> Unit,
onOpenWebViewBridge: () -> Unit,
) {
// Navigate to result when screen changes
LaunchedEffect(viewModel.currentScreen) {
@@ -125,6 +126,20 @@ fun HomeScreen(
)
}
Button(
onClick = onOpenWebViewBridge,
modifier =
Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp),
) {
Text(
text = "Open WebView Bridge PoC",
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}

View File

@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.minipay.screens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import xyz.self.minipay.webview.BridgeMethodException
import xyz.self.minipay.webview.MethodRegistry
import xyz.self.minipay.webview.ProviderError
import xyz.self.minipay.webview.ProviderErrorCodes
@Composable
fun WebViewBridgeScreen() {
val registry =
remember {
MethodRegistry().apply {
registerMethod("demo_echo") { params ->
Result.success(
buildJsonObject {
put("ok", JsonPrimitive(true))
put("echo", params ?: JsonNull)
},
)
}
registerMethod("demo_reject") {
Result.failure(
BridgeMethodException(
ProviderError(
code = ProviderErrorCodes.INVALID_PARAMS,
message = "demo_reject always fails",
),
),
)
}
}
}
PlatformWebViewBridge(registry = registry)
}
@Composable
expect fun PlatformWebViewBridge(registry: MethodRegistry)

View File

@@ -0,0 +1,239 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.minipay.webview
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
object ProviderErrorCodes {
const val UNKNOWN_METHOD = 4200
const val INVALID_PARAMS = -32602
const val INTERNAL_ERROR = -32603
}
@Serializable
data class ProviderError(
val code: Int,
val message: String,
val data: JsonElement? = null,
)
@Serializable
data class ProviderRequest(
val id: String,
val method: String,
val params: JsonElement? = null,
)
@Serializable
data class ProviderResponse(
val id: String,
val result: JsonElement? = null,
val error: ProviderError? = null,
)
typealias MethodHandler = (params: JsonElement?) -> Result<JsonElement?>
class BridgeMethodException(
val providerError: ProviderError,
) : Exception(providerError.message)
class MethodRegistry {
private val handlers = mutableMapOf<String, MethodHandler>()
fun registerMethod(
name: String,
handler: MethodHandler,
) {
handlers[name] = handler
}
fun dispatch(request: ProviderRequest): ProviderResponse {
val handler =
handlers[request.method]
?: return request.errorResponse(
ProviderError(
code = ProviderErrorCodes.UNKNOWN_METHOD,
message = "Unsupported method: ${request.method}",
),
)
return handler(request.params)
.fold(
onSuccess = { result -> ProviderResponse(id = request.id, result = result) },
onFailure = { throwable ->
val providerError =
if (throwable is BridgeMethodException) {
throwable.providerError
} else {
ProviderError(
code = ProviderErrorCodes.INTERNAL_ERROR,
message = throwable.message ?: "Internal error",
)
}
request.errorResponse(providerError)
},
)
}
private fun ProviderRequest.errorResponse(error: ProviderError): ProviderResponse =
ProviderResponse(id = id, error = error)
}
const val ETHEREUM_BRIDGE_CHANNEL = "SelfEthereumBridge"
const val ETHEREUM_BRIDGE_STUB =
"""
(() => {
if (window.ethereum && window.ethereum.__selfBridgeReady) {
return;
}
const pending = new Map();
let nextRequestId = 1;
const rejectWithProviderError = (reject, error) => {
reject({
code: error?.code ?? -32603,
message: error?.message ?? 'Internal error',
data: error?.data ?? null,
});
};
window.__selfEthereumResolve = (responseJson) => {
let response;
try {
response = JSON.parse(responseJson);
} catch (_error) {
return;
}
const entry = pending.get(response.id);
if (!entry) {
return;
}
pending.delete(response.id);
if (response.error) {
rejectWithProviderError(entry.reject, response.error);
return;
}
entry.resolve(response.result ?? null);
};
const sendToNative = (payload) => {
if (window.webkit?.messageHandlers?.SelfEthereumBridge) {
window.webkit.messageHandlers.SelfEthereumBridge.postMessage(payload);
return;
}
if (window.SelfEthereumBridge?.postMessage) {
window.SelfEthereumBridge.postMessage(payload);
return;
}
throw new Error('Native bridge is unavailable');
};
window.ethereum = {
__selfBridgeReady: true,
request(args) {
return new Promise((resolve, reject) => {
const { method, params } = args || {};
if (!method || typeof method !== 'string') {
rejectWithProviderError(reject, {
code: -32602,
message: 'Invalid params: method must be a string',
});
return;
}
const id = String(nextRequestId++);
pending.set(id, { resolve, reject });
try {
sendToNative(JSON.stringify({ id, method, params: params ?? null }));
} catch (error) {
pending.delete(id);
rejectWithProviderError(reject, {
code: -32603,
message: error?.message || 'Failed to call native bridge',
});
}
});
},
};
})();
"""
const val BRIDGE_DEMO_HTML =
"""
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MiniPay Bridge PoC</title>
</head>
<body style="font-family: sans-serif; padding: 16px;">
<h2>MiniPay Bridge PoC</h2>
<p>Uses <code>window.ethereum.request</code> through native bridge.</p>
<button onclick="runEcho()">demo_echo</button>
<button onclick="runReject()">demo_reject</button>
<button onclick="runUnknown()">foo (unknown)</button>
<button onclick="runConcurrent()">concurrent demo</button>
<pre id="output" style="margin-top: 16px; white-space: pre-wrap;"></pre>
<script>
const output = document.getElementById('output');
const log = (label, value) => {
output.textContent += `${'$'}{label}: ${'$'}{JSON.stringify(value)}\\n`;
};
async function runEcho() {
try {
const result = await window.ethereum.request({
method: 'demo_echo',
params: { from: 'html', time: Date.now() },
});
log('demo_echo resolved', result);
} catch (error) {
log('demo_echo rejected', error);
}
}
async function runReject() {
try {
const result = await window.ethereum.request({ method: 'demo_reject' });
log('demo_reject resolved', result);
} catch (error) {
log('demo_reject rejected', error);
}
}
async function runUnknown() {
try {
const result = await window.ethereum.request({ method: 'foo' });
log('foo resolved', result);
} catch (error) {
log('foo rejected', error);
}
}
async function runConcurrent() {
const [echo, unknown] = await Promise.allSettled([
window.ethereum.request({ method: 'demo_echo', params: { mode: 'parallel' } }),
window.ethereum.request({ method: 'foo' }),
]);
log('concurrent demo_echo', echo);
log('concurrent foo', unknown);
}
</script>
</body>
</html>
"""

View File

@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.minipay.screens
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.interop.UIKitView
import kotlin.native.ref.WeakReference
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.readValue
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import platform.CoreGraphics.CGRectZero
import platform.darwin.NSObject
import platform.WebKit.WKScriptMessage
import platform.WebKit.WKScriptMessageHandlerProtocol
import platform.WebKit.WKUserContentController
import platform.WebKit.WKUserScript
import platform.WebKit.WKUserScriptInjectionTime.WKUserScriptInjectionTimeAtDocumentStart
import platform.WebKit.WKWebView
import platform.WebKit.WKWebViewConfiguration
import xyz.self.minipay.webview.BRIDGE_DEMO_HTML
import xyz.self.minipay.webview.BridgeMethodException
import xyz.self.minipay.webview.ETHEREUM_BRIDGE_CHANNEL
import xyz.self.minipay.webview.ETHEREUM_BRIDGE_STUB
import xyz.self.minipay.webview.MethodRegistry
import xyz.self.minipay.webview.ProviderError
import xyz.self.minipay.webview.ProviderErrorCodes
import xyz.self.minipay.webview.ProviderRequest
import xyz.self.minipay.webview.ProviderResponse
private val json = Json { ignoreUnknownKeys = true }
@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun PlatformWebViewBridge(registry: MethodRegistry) {
UIKitView(
modifier = Modifier,
factory = {
val bridge = IosEthereumBridge(registry)
val userContentController = WKUserContentController()
userContentController.addScriptMessageHandler(
WeakMessageHandlerProxy(bridge),
ETHEREUM_BRIDGE_CHANNEL,
)
userContentController.addUserScript(
WKUserScript(
source = ETHEREUM_BRIDGE_STUB,
injectionTime = WKUserScriptInjectionTimeAtDocumentStart,
forMainFrameOnly = true,
),
)
val config = WKWebViewConfiguration()
config.userContentController = userContentController
WKWebView(frame = CGRectZero.readValue(), configuration = config).apply {
bridge.attach(this)
loadHTMLString(BRIDGE_DEMO_HTML, baseURL = null)
}
},
update = {},
)
}
@OptIn(ExperimentalForeignApi::class)
private class IosEthereumBridge(
private val registry: MethodRegistry,
) : NSObject(), WKScriptMessageHandlerProtocol {
private val pendingRequests = mutableMapOf<String, (ProviderResponse) -> Unit>()
private var webView: WKWebView? = null
fun attach(webView: WKWebView) {
this.webView = webView
}
override fun userContentController(
userContentController: WKUserContentController,
didReceiveScriptMessage: WKScriptMessage,
) {
val requestJson = didReceiveScriptMessage.body.toString()
val request =
try {
json.decodeFromString<ProviderRequest>(requestJson)
} catch (_: Exception) {
return
}
pendingRequests[request.id] = { response -> sendResponseToJs(response) }
val response =
try {
registry.dispatch(request)
} catch (exception: BridgeMethodException) {
ProviderResponse(id = request.id, error = exception.providerError)
} catch (exception: Exception) {
ProviderResponse(
id = request.id,
error =
ProviderError(
code = ProviderErrorCodes.INTERNAL_ERROR,
message = exception.message ?: "Internal error",
),
)
}
respond(response)
}
private fun respond(response: ProviderResponse) {
val callback = pendingRequests.remove(response.id) ?: return
callback(response)
}
private fun sendResponseToJs(response: ProviderResponse) {
val responseJson = json.encodeToString(ProviderResponse.serializer(), response)
val jsStringLiteral = json.encodeToString(String.serializer(), responseJson)
val script = "window.__selfEthereumResolve($jsStringLiteral);"
webView?.evaluateJavaScript(script, completionHandler = null)
}
}
@OptIn(ExperimentalForeignApi::class, kotlin.experimental.ExperimentalNativeApi::class)
private class WeakMessageHandlerProxy(
handler: WKScriptMessageHandlerProtocol,
) : NSObject(), WKScriptMessageHandlerProtocol {
private val weakHandler = WeakReference(handler)
override fun userContentController(
userContentController: WKUserContentController,
didReceiveScriptMessage: WKScriptMessage,
) {
weakHandler.get()?.userContentController(userContentController, didReceiveScriptMessage)
}
}