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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
820 changes: 509 additions & 311 deletions Cargo.lock

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@ import androidx.compose.ui.unit.dp
import com.worldcoin.idkit.IDKit
import com.worldcoin.idkit.IDKitRequest
import com.worldcoin.idkit.IDKitRequestConfig
import com.worldcoin.idkit.documentLegacy
import com.worldcoin.idkit.idkitResultToJson
import com.worldcoin.idkit.deviceLegacy
import com.worldcoin.idkit.orbLegacy
import com.worldcoin.idkit.secureDocumentLegacy
import com.worldcoin.idkit.selfieCheckLegacy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -123,6 +127,10 @@ private fun SampleScreen(
selected = model.environment,
onSelect = { model.environment = it },
)
LegacyPresetSelector(
selected = model.legacyPreset,
onSelect = { model.legacyPreset = it },
)

Button(
onClick = { model.generateRequestURL() },
Expand Down Expand Up @@ -225,11 +233,65 @@ private fun EnvironmentSelector(
}
}

@Composable
private fun LegacyPresetSelector(
selected: SampleLegacyPreset,
onSelect: (SampleLegacyPreset) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Legacy preset", style = MaterialTheme.typography.labelLarge)

SampleLegacyPreset.entries
.chunked(2)
.forEach { rowItems ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
rowItems.forEach { preset ->
if (selected == preset) {
FilledTonalButton(
onClick = {},
modifier = Modifier.weight(1f),
) {
Text(preset.label)
}
} else {
OutlinedButton(
onClick = { onSelect(preset) },
modifier = Modifier.weight(1f),
) {
Text(preset.label)
}
}
}
}
}
}
}

private enum class SampleEnvironment {
PRODUCTION,
STAGING,
}

private enum class SampleLegacyPreset(val label: String) {
ORB("orb"),
SECURE_DOCUMENT("secure document"),
DOCUMENT("document"),
DEVICE("device"),
SELFIE_CHECK("selfie check"),
;

fun toPreset(signal: String) = when (this) {
ORB -> orbLegacy(signal = signal)
SECURE_DOCUMENT -> secureDocumentLegacy(signal = signal)
DOCUMENT -> documentLegacy(signal = signal)
DEVICE -> deviceLegacy(signal = signal)
SELFIE_CHECK -> selfieCheckLegacy(signal = signal)
}
}

private class SampleModel {
var signatureEndpoint by mutableStateOf("https://idkit-js-example.vercel.app/api/rp-signature")
var verifyEndpoint by mutableStateOf("https://idkit-js-example.vercel.app/api/verify-proof")
Expand All @@ -238,6 +300,7 @@ private class SampleModel {
var action by mutableStateOf("test-action")
var signal by mutableStateOf("signal")
var environment by mutableStateOf(SampleEnvironment.PRODUCTION)
var legacyPreset by mutableStateOf(SampleLegacyPreset.DEVICE)
private val returnToURL = "idkitsample://callback"
var connectorURI by mutableStateOf<String?>(null)
var logs by mutableStateOf("")
Expand Down Expand Up @@ -290,17 +353,19 @@ private class SampleModel {
SampleEnvironment.STAGING -> Environment.STAGING
},
)
val preset = legacyPreset.toPreset(signal)

val request = IDKit
.request(config)
.preset(deviceLegacy(signal = signal))
.preset(preset)

completionJob?.cancel()
connectorURI = request.connectorURI
pendingRequest = request
deepLinkReceivedForPendingRequest = false

android.util.Log.i("IDKitSample", "IDKit connector URL: ${request.connectorURI}")
log("Using legacy preset: ${legacyPreset.label}")
log("Generated request ID: ${request.requestId}")
log("Configured return_to callback: $returnToURL")
startPollingForRequest(
Expand Down Expand Up @@ -383,28 +448,15 @@ private class SampleModel {
}

is com.worldcoin.idkit.IDKitStatus.Failed -> {
if (status.error == com.worldcoin.idkit.IDKitErrorCode.CONNECTION_FAILED) {
log(
"Bridge poll returned connection_failed " +
"(foreground=$appIsForeground, deepLinkReceived=$deepLinkReceivedForPendingRequest).",
)
}

val shouldRetryConnectionFailure =
status.error == com.worldcoin.idkit.IDKitErrorCode.CONNECTION_FAILED &&
!deepLinkReceivedForPendingRequest &&
pendingRequest?.requestId == request.requestId

if (shouldRetryConnectionFailure) {
log("Bridge not ready yet (connection_failed). Retrying...")
delay(pollIntervalMs)
continue
}

log("Proof completion failed: ${status.error.rawValue}")
return@launch
}

is com.worldcoin.idkit.IDKitStatus.NetworkingError -> {
log("Networking error (${status.error.rawValue}), retrying...")
delay(pollIntervalMs)
}

com.worldcoin.idkit.IDKitStatus.AwaitingConfirmation,
com.worldcoin.idkit.IDKitStatus.WaitingForConnection -> {
delay(pollIntervalMs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ sealed interface IDKitStatus {
data object AwaitingConfirmation : IDKitStatus
data class Confirmed(val result: IDKitResult) : IDKitStatus
data class Failed(val error: IDKitErrorCode) : IDKitStatus
data class NetworkingError(val error: IDKitErrorCode) : IDKitStatus
}

sealed interface IDKitCompletionResult {
Expand Down Expand Up @@ -145,6 +146,7 @@ class IDKitRequest internal constructor(
when (val status = pollStatusOnce()) {
is IDKitStatus.Confirmed -> return IDKitCompletionResult.Success(status.result)
is IDKitStatus.Failed -> return IDKitCompletionResult.Failure(status.error)
is IDKitStatus.NetworkingError -> delay(pollIntervalMs.toLong())
IDKitStatus.AwaitingConfirmation,
IDKitStatus.WaitingForConnection -> delay(pollIntervalMs.toLong())
}
Expand All @@ -166,6 +168,7 @@ class IDKitRequest internal constructor(
StatusWrapper.AwaitingConfirmation -> IDKitStatus.AwaitingConfirmation
is StatusWrapper.Confirmed -> IDKitStatus.Confirmed(status.result)
is StatusWrapper.Failed -> IDKitStatus.Failed(IDKitErrorCode.from(status.error))
is StatusWrapper.NetworkingError -> IDKitStatus.NetworkingError(IDKitErrorCode.from(status.error))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,16 @@ fun IDKitRequest.statusFlow(pollInterval: Duration = 3.seconds): Flow<IDKitStatu

while (true) {
val current = pollStatusOnce()
if (current != last) {
// Networking errors are silently retried, consistent with pollUntilCompletion
if (current != last && current !is IDKitStatus.NetworkingError) {
last = current
emit(current)
}

when (current) {
is IDKitStatus.Confirmed,
is IDKitStatus.Failed -> return@flow
is IDKitStatus.NetworkingError,
IDKitStatus.AwaitingConfirmation,
IDKitStatus.WaitingForConnection -> {
delay(pollInterval)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ class IDKitTests {
IDKitStatus.Failed(IDKitErrorCode.INVALID_NETWORK),
IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.INVALID_NETWORK)),
)
assertEquals(
IDKitStatus.NetworkingError(IDKitErrorCode.CONNECTION_FAILED),
IDKitRequest.mapStatus(StatusWrapper.NetworkingError(AppError.CONNECTION_FAILED)),
)
}

@Test
Expand Down Expand Up @@ -138,6 +142,29 @@ class IDKitTests {
assertEquals(IDKitCompletionResult.Failure(IDKitErrorCode.CANCELLED), completion)
}

@Test
fun `pollUntilCompletion recovers from networking errors`() = runBlocking {
val statuses = ArrayDeque(
listOf(
IDKitStatus.WaitingForConnection,
IDKitStatus.NetworkingError(IDKitErrorCode.CONNECTION_FAILED),
IDKitStatus.NetworkingError(IDKitErrorCode.CONNECTION_FAILED),
IDKitStatus.AwaitingConfirmation,
IDKitStatus.Confirmed(sampleResult()),
),
)

val request = IDKitRequest.forTesting(
connectorURI = "https://world.org/verify?t=wld",
requestId = "7a6ff287-c95f-4330-b3de-9447f77ca3f9",
) {
statuses.removeFirstOrNull() ?: IDKitStatus.WaitingForConnection
}

val completion = request.pollUntilCompletion(IDKitPollOptions(pollIntervalMs = 1u, timeoutMs = 1_000u))
assertEquals(IDKitCompletionResult.Success(sampleResult()), completion)
}

@Test
fun `pollUntilCompletion app failure path`() = runBlocking {
val request = IDKitRequest.forTesting(
Expand Down
25 changes: 22 additions & 3 deletions rust/core/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,8 @@ pub enum StatusWrapper {
Confirmed { result: IDKitResult },
/// Request has failed
Failed { error: AppError },
/// Network/transport error — safe to retry
NetworkingError { error: AppError },
}

#[cfg(feature = "ffi")]
Expand Down Expand Up @@ -1083,6 +1085,18 @@ fn to_app_error(error: &Error) -> AppError {
}
}

/// Networking errors are network/transport-level failures where the bridge
/// never returned a meaningful response. These are safe to retry because
/// the request itself is valid — only the delivery failed.
fn is_networking_error(error: &Error) -> bool {
match error {
Error::Timeout | Error::ConnectionFailed | Error::BridgeError(_) => true,
#[cfg(any(feature = "bridge", feature = "bridge-wasm"))]
Error::Http(err) => err.is_timeout() || err.is_request(),
_ => false,
}
}

#[cfg(feature = "ffi")]
#[uniffi::export]
#[allow(clippy::needless_pass_by_value)]
Expand Down Expand Up @@ -1116,9 +1130,14 @@ impl IDKitRequestWrapper {
pub fn poll_status_once(&self) -> StatusWrapper {
match self.runtime.block_on(self.inner.poll_for_status()) {
Ok(status) => status.into(),
Err(err) => StatusWrapper::Failed {
error: to_app_error(&err),
},
Err(err) => {
let app_error = to_app_error(&err);
if is_networking_error(&err) {
StatusWrapper::NetworkingError { error: app_error }
} else {
StatusWrapper::Failed { error: app_error }
}
}
}
}
}
Expand Down
52 changes: 40 additions & 12 deletions swift/Examples/IDKitSampleApp/IDKitSampleApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@ enum SampleEnvironment: String, CaseIterable, Identifiable {
var id: String { rawValue }
}

enum SampleLegacyPreset: String, CaseIterable, Identifiable {
case orb
case secureDocument = "secure document"
case document
case device
case selfieCheck = "selfie check"

var id: String { rawValue }

func toPreset(signal: String) -> Preset {
switch self {
case .orb:
orbLegacy(signal: signal)
case .secureDocument:
secureDocumentLegacy(signal: signal)
case .document:
documentLegacy(signal: signal)
case .device:
deviceLegacy(signal: signal)
case .selfieCheck:
selfieCheckLegacy(signal: signal)
}
}
}

struct ContentView: View {
@StateObject private var model = SampleModel()

Expand Down Expand Up @@ -36,6 +61,12 @@ struct ContentView: View {
}
}
.pickerStyle(.segmented)

Picker("Legacy preset", selection: $model.legacyPreset) {
ForEach(SampleLegacyPreset.allCases) { preset in
Text(preset.rawValue).tag(preset)
}
}
}

Section {
Expand Down Expand Up @@ -85,6 +116,7 @@ final class SampleModel: ObservableObject {
@Published var action = "test-action"
@Published var signal = "signal"
@Published var environment: SampleEnvironment = .production
@Published var legacyPreset: SampleLegacyPreset = .orb
@Published var connectorURL: URL?
@Published var logs = ""
@Published var isLoading = false
Expand Down Expand Up @@ -132,14 +164,17 @@ final class SampleModel: ObservableObject {
}()
)

let request = try IDKit.request(config: config).preset(orbLegacy(signal: signal))
let request = try IDKit
.request(config: config)
.preset(legacyPreset.toPreset(signal: signal))

completionTask?.cancel()
connectorURL = request.connectorURL
pendingRequest = request
deepLinkReceivedForPendingRequest = false

print("IDKit connector URL: \(request.connectorURL.absoluteString)")
log("Using legacy preset: \(legacyPreset.rawValue)")
log("Generated request ID: \(request.requestID.uuidString)")
log("Configured return_to callback: \(returnToURL)")
startPollingForRequest(request: request, reason: "request generation")
Expand Down Expand Up @@ -226,20 +261,13 @@ final class SampleModel: ObservableObject {
return

case .failed(let error):
let shouldRetryConnectionFailure =
error == .connectionFailed &&
!deepLinkReceivedForPendingRequest &&
pendingRequest?.requestID == request.requestID

if shouldRetryConnectionFailure {
log("Bridge not ready yet (connection_failed). Retrying...")
try await Task.sleep(nanoseconds: pollIntervalNs)
continue
}

log("Proof completion failed: \(error.rawValue)")
return

case .networkingError(let error):
log("Networking error (\(error.rawValue)), retrying...")
try await Task.sleep(nanoseconds: pollIntervalNs)

case .awaitingConfirmation, .waitingForConnection:
try await Task.sleep(nanoseconds: pollIntervalNs)
}
Expand Down
Loading
Loading