From 2328baf3af945a1969c2963c5cea2b84b6746367 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Wed, 10 Jun 2026 16:29:11 +0300 Subject: [PATCH 1/6] feat(native): support OMS SDK alpha.2 --- API.md | 91 ++++- OmsClientReactNativeSdk.podspec | 2 +- README.md | 11 +- TESTING.md | 2 +- android/build.gradle | 2 +- .../OmsClientReactNativeSdkModule.kt | 208 +++++++++++- examples/sdk-example/ios/Podfile.lock | 130 ++++---- .../trails-actions-example/ios/Podfile.lock | 132 ++++---- ios/OmsClientReactNativeSdk.mm | 14 + ios/OmsClientReactNativeSdkImpl.swift | 314 ++++++++++-------- src/NativeOmsClientReactNativeSdk.ts | 80 ++++- src/client.native.ts | 22 +- src/client.ts | 8 + src/index.tsx | 5 + src/types.ts | 12 + src/units.ts | 7 +- 16 files changed, 738 insertions(+), 302 deletions(-) diff --git a/API.md b/API.md index aff5156..88dd013 100644 --- a/API.md +++ b/API.md @@ -9,8 +9,8 @@ This document describes the public TypeScript API for external consumers of npm install @0xsequence/oms-react-native-sdk ``` -Android resolves `io.github.0xsequence:oms-client-kotlin-sdk:0.1.0-alpha.1`. -iOS resolves `oms-client-swift-sdk` `0.1.0-alpha.1`. +Android resolves `io.github.0xsequence:oms-client-kotlin-sdk:0.1.0-alpha.2`. +iOS resolves `oms-client-swift-sdk` `0.1.0-alpha.2`. ## Configure @@ -35,6 +35,9 @@ APIs. `publishableKey` is sent to the native SDKs as the OMS publishable key, an ```ts getWalletAddress(): Promise getSession(): Promise +onSessionExpired( + listener: (event: OmsClientSessionExpiredEvent) => void +): EventSubscription signOut(): Promise ``` @@ -45,10 +48,17 @@ type OmsClientSessionState = { loginType: 'Email' | 'GoogleAuth' | 'Oidc' | null; sessionEmail: string | null; }; + +type OmsClientSessionExpiredEvent = { + session: OmsClientSessionState; + expiredAt: string; +}; ``` `getSession` reports completed wallet-session metadata only. Pending OTP, redirect verifier state, and signer details are native SDK internals. +`onSessionExpired` emits when the native wallet session expires; use the expired +session snapshot to route users back to sign-in or prefill re-authentication UI. ## Email Auth @@ -59,6 +69,7 @@ completeEmailAuth({ code: string; walletSelection?: 'automatic' | 'manual'; walletType?: 'ethereum'; + sessionLifetimeSeconds?: number | null; }): Promise ``` @@ -71,6 +82,7 @@ signInWithOidcIdToken({ audience: string; walletSelection?: 'automatic' | 'manual'; walletType?: 'ethereum'; + sessionLifetimeSeconds?: number | null; }): Promise ``` @@ -104,6 +116,7 @@ startOidcRedirectAuth({ walletType?: 'ethereum'; relayRedirectUri?: string | null; authorizeParams?: Record | null; + loginHint?: string | null; }): Promise<{ authorizationUrl: string; state: string; @@ -113,6 +126,7 @@ startOidcRedirectAuth({ handleOidcRedirectCallback({ callbackUrl?: string | null; walletSelection?: 'automatic' | 'manual'; + sessionLifetimeSeconds?: number | null; }): Promise ``` @@ -122,6 +136,9 @@ pass the resulting app-link URL to `handleOidcRedirectCallback`. When `relayRedirectUri` is omitted, the provider default is used. Pass `relayRedirectUri: null` to explicitly use the app `redirectUri` directly. +For Google OIDC providers, `loginHint` is sent as OAuth `login_hint`; if omitted, +the native SDKs may reuse the previous session email during re-authentication. +Auth completion methods default to a one-week session lifetime. ## Auth Results @@ -346,9 +363,16 @@ type OmsTokenBalance = { accountAddress: string | null; tokenId: string | null; balance: string | null; + balanceUSD?: string | null; + priceUSD?: string | null; + priceUpdatedAt?: string | null; blockHash: string | null; blockNumber?: number | null; chainId?: number | null; + uniqueCollectibles?: string | null; + isSummary?: boolean | null; + contractInfo?: OmsTokenContractInfo | null; + tokenMetadata?: OmsTokenMetadata | null; }; type OmsTokenBalancesPage = { @@ -356,11 +380,67 @@ type OmsTokenBalancesPage = { pageSize: number; more: boolean; }; + +type OmsTokenContractInfo = { + chainId?: number | null; + address?: string | null; + source?: string | null; + name?: string | null; + type?: string | null; + symbol?: string | null; + decimals?: number | null; + logoURI?: string | null; + deployed?: boolean | null; + bytecodeHash?: string | null; + extensions?: object | null; + updatedAt?: string | null; + queuedAt?: string | null; + status?: string | null; +}; + +type OmsTokenMetadata = { + chainId?: number | null; + contractAddress?: string | null; + tokenId?: string | null; + source?: string | null; + name?: string | null; + description?: string | null; + image?: string | null; + video?: string | null; + audio?: string | null; + properties?: object | null; + attributes?: object[] | null; + imageData?: string | null; + externalUrl?: string | null; + backgroundColor?: string | null; + animationUrl?: string | null; + decimals?: number | null; + updatedAt?: string | null; + assets?: OmsTokenMetadataAsset[] | null; + status?: string | null; + queuedAt?: string | null; + lastFetched?: string | null; +}; + +type OmsTokenMetadataAsset = { + id?: number | null; + collectionId?: number | null; + tokenId?: string | null; + url?: string | null; + metadataField?: string | null; + name?: string | null; + filesize?: number | null; + mimeType?: string | null; + width?: number | null; + height?: number | null; + updatedAt?: string | null; +}; ``` Omit `contractAddress` to query balances across token contracts. Pass `page` to request a later page or a custom page size. When `page` is undefined, the request defaults to page `0` with up to `40` entries. +Pass `includeMetadata: true` to request `contractInfo` and `tokenMetadata`. ## Wallet ID Token @@ -416,7 +496,6 @@ parseUnits( formatUnits(value: string | bigint, decimals?: number): string ``` -`parseUnits` defaults to `roundingMode: 'reject'`, matching Swift and common -JavaScript parseUnits behavior. Use `roundingMode: 'nearest'` to match the -Kotlin SDK helper, which rounds fractional precision beyond `decimals` to the -nearest base unit. +`parseUnits` defaults to `roundingMode: 'nearest'`, matching the native SDK +helpers by rounding fractional precision beyond `decimals` to the nearest base +unit. Use `roundingMode: 'reject'` to fail on non-zero excess precision. diff --git a/OmsClientReactNativeSdk.podspec b/OmsClientReactNativeSdk.podspec index 2e44970..44931bc 100644 --- a/OmsClientReactNativeSdk.podspec +++ b/OmsClientReactNativeSdk.podspec @@ -16,7 +16,7 @@ Pod::Spec.new do |s| s.source_files = "ios/**/*.{h,m,mm,swift,cpp}" s.private_header_files = "ios/**/*.h" s.swift_version = "6.0" - s.dependency "oms-client-swift-sdk", "0.1.0-alpha.1" + s.dependency "oms-client-swift-sdk", "0.1.0-alpha.2" s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" } diff --git a/README.md b/README.md index 247317b..bf71c97 100644 --- a/README.md +++ b/README.md @@ -105,11 +105,12 @@ transactions. ```ts const raw = parseUnits('12.34', 6); // "12340000" const formatted = formatUnits(raw, 6); // "12.34" -const rounded = parseUnits('1.235', 2, { roundingMode: 'nearest' }); // "124" +const rounded = parseUnits('1.235', 2); // "124" ``` -By default `parseUnits` rejects fractional precision beyond `decimals`. -Pass `{ roundingMode: 'nearest' }` when you want Kotlin-compatible rounding. +By default `parseUnits` rounds fractional precision beyond `decimals` to the +nearest base unit, matching the native SDKs. Pass `{ roundingMode: 'reject' }` +to fail on non-zero excess precision. ## API Reference @@ -133,8 +134,8 @@ See [API.md](./API.md) for the public API surface and TypeScript shapes. ## Native SDK Dependencies The React Native SDK owns its native SDK dependencies. Android resolves -`io.github.0xsequence:oms-client-kotlin-sdk:0.1.0-alpha.1` from Maven, and -iOS resolves `oms-client-swift-sdk` `0.1.0-alpha.1` from CocoaPods. +`io.github.0xsequence:oms-client-kotlin-sdk:0.1.0-alpha.2` from Maven, and +iOS resolves `oms-client-swift-sdk` `0.1.0-alpha.2` from CocoaPods. The React Native wrapper itself is distributed through npm. React Native autolinking consumes the wrapper podspec and Android project from diff --git a/TESTING.md b/TESTING.md index 57c19f9..ff2073f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -4,7 +4,7 @@ How testing works in this repo. `AGENTS.md` points here so agents know how to ve ## Current state -**No automated test suite exists yet.** The repo is in early alpha (`0.1.0-alpha.1`). Until a test +**No automated test suite exists yet.** The repo is in early alpha. Until a test runner is set up, verification is manual (see checklist below). When tests are added, this file should be updated with the runner, locations, and commands. diff --git a/android/build.gradle b/android/build.gradle index 2c83dee..431cdcc 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -4,7 +4,7 @@ buildscript { minSdkVersion: 26, compileSdkVersion: 36, targetSdkVersion: 36, - omsClientKotlinSdkVersion: "0.1.0-alpha.1", + omsClientKotlinSdkVersion: "0.1.0-alpha.2", kotlinxSerializationJsonVersion: "1.9.0" ] diff --git a/android/src/main/java/com/omsclientreactnativesdk/OmsClientReactNativeSdkModule.kt b/android/src/main/java/com/omsclientreactnativesdk/OmsClientReactNativeSdkModule.kt index 47047e8..0bf4464 100644 --- a/android/src/main/java/com/omsclientreactnativesdk/OmsClientReactNativeSdkModule.kt +++ b/android/src/main/java/com/omsclientreactnativesdk/OmsClientReactNativeSdkModule.kt @@ -3,10 +3,13 @@ package com.omsclientreactnativesdk import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.omsclient.kotlin_sdk.Network import com.omsclient.kotlin_sdk.OMSClient import com.omsclient.kotlin_sdk.OMSClientSessionState +import com.omsclient.kotlin_sdk.OmsSdkErrorCode +import com.omsclient.kotlin_sdk.OmsSdkException import com.omsclient.kotlin_sdk.models.AbiArg import com.omsclient.kotlin_sdk.models.CredentialInfo import com.omsclient.kotlin_sdk.models.FeeOption @@ -21,6 +24,9 @@ import com.omsclient.kotlin_sdk.models.TokenBalance import com.omsclient.kotlin_sdk.models.TokenBalancesPage import com.omsclient.kotlin_sdk.models.TokenBalancesPageRequest import com.omsclient.kotlin_sdk.models.TokenBalancesResult +import com.omsclient.kotlin_sdk.models.TokenContractInfo +import com.omsclient.kotlin_sdk.models.TokenMetadata +import com.omsclient.kotlin_sdk.models.TokenMetadataAsset import com.omsclient.kotlin_sdk.models.TransactionMode import com.omsclient.kotlin_sdk.models.TransactionStatusPollingOptions import com.omsclient.kotlin_sdk.models.TransactionStatusResponse @@ -33,6 +39,7 @@ import com.omsclient.kotlin_sdk.wallet.OidcRedirectAuthResult import com.omsclient.kotlin_sdk.wallet.PendingWalletSelection import com.omsclient.kotlin_sdk.wallet.WalletSelectionBehavior import com.omsclient.kotlin_sdk.wallet.WalletSelectionResult +import com.omsclient.kotlin_sdk.wallet.WalletClient import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -41,7 +48,11 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject @@ -55,6 +66,7 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val pendingFeeOptionSelections = ConcurrentHashMap>() private val pendingWalletSelections = ConcurrentHashMap() + private var sessionExpiredUnsubscribe: (() -> Unit)? = null private var client: OMSClient? = null override fun configure( @@ -67,9 +79,10 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : ) { try { pendingWalletSelections.clear() + sessionExpiredUnsubscribe?.invoke() client = OMSClient( context = reactApplicationContext, - publicApiKey = publishableKey, + publishableKey = publishableKey, projectId = projectId, environment = OMSClientEnvironment( walletApiUrl ?: OMSClientEnvironment.walletApiUrlDefault, @@ -77,6 +90,14 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : indexerUrlTemplate ?: OMSClientEnvironment.indexerUrlTemplateDefault ) ) + sessionExpiredUnsubscribe = client?.wallet?.onSessionExpired { event -> + emitOnSessionExpired( + Arguments.createMap().apply { + putMap("session", sessionMap(event.session)) + putString("expiredAt", event.expiredAt.toString()) + } + ) + } promise.resolve(null) } catch (throwable: Throwable) { reject(promise, throwable) @@ -118,6 +139,7 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : code: String, walletSelection: String?, walletType: String?, + sessionLifetimeSeconds: String?, promise: Promise ) { launch(promise) { @@ -125,7 +147,8 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : requireClient().wallet.completeEmailAuth( code = code, walletSelection = walletSelection.toWalletSelectionBehavior(), - walletType = walletType.toWalletType() + walletType = walletType.toWalletType(), + sessionLifetimeSeconds = sessionLifetimeSeconds.toSessionLifetimeSeconds() ) ) } @@ -137,6 +160,7 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : audience: String, walletSelection: String?, walletType: String?, + sessionLifetimeSeconds: String?, promise: Promise ) { launch(promise) { @@ -146,7 +170,8 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : issuer = issuer, audience = audience, walletSelection = walletSelection.toWalletSelectionBehavior(), - walletType = walletType.toWalletType() + walletType = walletType.toWalletType(), + sessionLifetimeSeconds = sessionLifetimeSeconds.toSessionLifetimeSeconds() ) ) } @@ -158,6 +183,7 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : walletType: String?, relayRedirectUri: String?, authorizeParamsJson: String?, + loginHint: String?, promise: Promise ) { launch(promise) { @@ -166,7 +192,8 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : redirectUri = redirectUri, walletType = walletType.toWalletType(), relayRedirectUri = relayRedirectUri, - authorizeParams = authorizeParamsJson.toStringMap("authorizeParams") ?: emptyMap() + authorizeParams = authorizeParamsJson.toStringMap("authorizeParams") ?: emptyMap(), + loginHint = loginHint ) Arguments.createMap().apply { putString("authorizationUrl", result.authorizationUrl) @@ -179,13 +206,15 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : override fun handleOidcRedirectCallback( callbackUrl: String?, walletSelection: String?, + sessionLifetimeSeconds: String?, promise: Promise ) { launch(promise) { when ( val result = requireClient().wallet.handleOidcRedirectCallback( callbackUrl = callbackUrl, - walletSelection = walletSelection.toWalletSelectionBehavior() + walletSelection = walletSelection.toWalletSelectionBehavior(), + sessionLifetimeSeconds = sessionLifetimeSeconds.toSessionLifetimeSeconds() ) ) { is OidcRedirectAuthResult.Completed -> @@ -510,6 +539,8 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : } override fun invalidate() { + sessionExpiredUnsubscribe?.invoke() + sessionExpiredUnsubscribe = null scope.cancel() super.invalidate() } @@ -668,9 +699,88 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : putNullableString("accountAddress", balance.accountAddress) putNullableString("tokenId", balance.tokenId) putNullableString("balance", balance.balance) + putNullableString("balanceUSD", balance.balanceUSD) + putNullableString("priceUSD", balance.priceUSD) + putNullableString("priceUpdatedAt", balance.priceUpdatedAt) putNullableString("blockHash", balance.blockHash) balance.blockNumber?.let { putDouble("blockNumber", it.toDouble()) } balance.chainId?.let { putDouble("chainId", it.toDouble()) } + putNullableString("uniqueCollectibles", balance.uniqueCollectibles) + balance.isSummary?.let { putBoolean("isSummary", it) } ?: putNull("isSummary") + balance.contractInfo?.let { putMap("contractInfo", tokenContractInfoMap(it)) } ?: putNull("contractInfo") + balance.tokenMetadata?.let { putMap("tokenMetadata", tokenMetadataMap(it)) } ?: putNull("tokenMetadata") + } + + private fun tokenContractInfoMap(info: TokenContractInfo): WritableMap = + Arguments.createMap().apply { + info.chainId?.let { putDouble("chainId", it.toDouble()) } ?: putNull("chainId") + putNullableString("address", info.address) + putNullableString("source", info.source) + putNullableString("name", info.name) + putNullableString("type", info.type) + putNullableString("symbol", info.symbol) + info.decimals?.let { putDouble("decimals", it.toDouble()) } ?: putNull("decimals") + putNullableString("logoURI", info.logoURI) + info.deployed?.let { putBoolean("deployed", it) } ?: putNull("deployed") + putNullableString("bytecodeHash", info.bytecodeHash) + info.extensions?.let { putMap("extensions", jsonObjectMap(it)) } ?: putNull("extensions") + putNullableString("updatedAt", info.updatedAt) + putNullableString("queuedAt", info.queuedAt) + putNullableString("status", info.status) + } + + private fun tokenMetadataMap(metadata: TokenMetadata): WritableMap = + Arguments.createMap().apply { + metadata.chainId?.let { putDouble("chainId", it.toDouble()) } ?: putNull("chainId") + putNullableString("contractAddress", metadata.contractAddress) + putNullableString("tokenId", metadata.tokenId) + putNullableString("source", metadata.source) + putNullableString("name", metadata.name) + putNullableString("description", metadata.description) + putNullableString("image", metadata.image) + putNullableString("video", metadata.video) + putNullableString("audio", metadata.audio) + metadata.properties?.let { putMap("properties", jsonObjectMap(it)) } ?: putNull("properties") + metadata.attributes?.let { + putArray( + "attributes", + Arguments.createArray().apply { + it.forEach { attribute -> pushMap(jsonObjectMap(attribute)) } + } + ) + } ?: putNull("attributes") + putNullableString("imageData", metadata.imageData) + putNullableString("externalUrl", metadata.externalUrl) + putNullableString("backgroundColor", metadata.backgroundColor) + putNullableString("animationUrl", metadata.animationUrl) + metadata.decimals?.let { putDouble("decimals", it.toDouble()) } ?: putNull("decimals") + putNullableString("updatedAt", metadata.updatedAt) + metadata.assets?.let { + putArray( + "assets", + Arguments.createArray().apply { + it.forEach { asset -> pushMap(tokenMetadataAssetMap(asset)) } + } + ) + } ?: putNull("assets") + putNullableString("status", metadata.status) + putNullableString("queuedAt", metadata.queuedAt) + putNullableString("lastFetched", metadata.lastFetched) + } + + private fun tokenMetadataAssetMap(asset: TokenMetadataAsset): WritableMap = + Arguments.createMap().apply { + asset.id?.let { putDouble("id", it.toDouble()) } ?: putNull("id") + asset.collectionId?.let { putDouble("collectionId", it.toDouble()) } ?: putNull("collectionId") + putNullableString("tokenId", asset.tokenId) + putNullableString("url", asset.url) + putNullableString("metadataField", asset.metadataField) + putNullableString("name", asset.name) + asset.filesize?.let { putDouble("filesize", it.toDouble()) } ?: putNull("filesize") + putNullableString("mimeType", asset.mimeType) + asset.width?.let { putDouble("width", it.toDouble()) } ?: putNull("width") + asset.height?.let { putDouble("height", it.toDouble()) } ?: putNull("height") + putNullableString("updatedAt", asset.updatedAt) } private fun transactionStatusMap(result: TransactionStatusResponse): WritableMap = @@ -781,6 +891,9 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : private fun String?.toLongOrNullParam(name: String): Long? = this?.toLongOrNull()?.takeIf { it >= 0 } ?: this?.let { error("$name must be a non-negative integer") } + private fun String?.toSessionLifetimeSeconds(): Long = + this?.toLongOrNullParam("sessionLifetimeSeconds") ?: WalletClient.DEFAULT_SESSION_LIFETIME_SECONDS + private fun statusPollingOptions( timeoutMs: String?, intervalMs: String?, @@ -799,7 +912,7 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : ) } - private fun String?.toJsonObjectMap(name: String): Map? { + private fun String?.toJsonObjectMap(name: String): Map? { val value = this ?: return null val element = json.parseToJsonElement(value) return (element as? JsonObject)?.toMap() ?: error("$name must be a JSON object") @@ -847,8 +960,89 @@ class OmsClientReactNativeSdkModule(reactContext: ReactApplicationContext) : } } + private fun jsonObjectMap(value: Map): WritableMap = + Arguments.createMap().apply { + value.forEach { (key, element) -> putJsonElement(key, element) } + } + + private fun jsonArray(value: Iterable): WritableArray = + Arguments.createArray().apply { + value.forEach { element -> pushJsonElement(element) } + } + + private fun WritableMap.putJsonElement(key: String, element: JsonElement) { + when (element) { + JsonNull -> putNull(key) + is JsonObject -> putMap(key, jsonObjectMap(element)) + is JsonArray -> putArray(key, jsonArray(element)) + is JsonPrimitive -> putJsonPrimitive(key, element) + } + } + + private fun WritableArray.pushJsonElement(element: JsonElement) { + when (element) { + JsonNull -> pushNull() + is JsonObject -> pushMap(jsonObjectMap(element)) + is JsonArray -> pushArray(jsonArray(element)) + is JsonPrimitive -> pushJsonPrimitive(element) + } + } + + private fun WritableMap.putJsonPrimitive(key: String, value: JsonPrimitive) { + if (value.isString) { + putString(key, value.content) + return + } + value.content.toBooleanStrictOrNull()?.let { + putBoolean(key, it) + return + } + value.content.toDoubleOrNull()?.let { + putDouble(key, it) + return + } + putString(key, value.content) + } + + private fun WritableArray.pushJsonPrimitive(value: JsonPrimitive) { + if (value.isString) { + pushString(value.content) + return + } + value.content.toBooleanStrictOrNull()?.let { + pushBoolean(it) + return + } + value.content.toDoubleOrNull()?.let { + pushDouble(it) + return + } + pushString(value.content) + } + private fun reject(promise: Promise, throwable: Throwable) { - promise.reject("oms_client_error", throwable.message, throwable) + val omsError = throwable as? OmsSdkException + if (omsError == null) { + promise.reject("oms_client_error", throwable.message, throwable) + return + } + + val code = omsError.code.bridgeCode() + promise.reject(code, omsError.message, throwable, omsError.userInfoMap(code)) + } + + private fun OmsSdkException.userInfoMap(code: String): WritableMap = + Arguments.createMap().apply { + putString("code", code) + putNullableString("operation", operation?.id) + status?.let { putDouble("status", it.toDouble()) } ?: putNull("status") + putNullableString("txnId", txnId) + putBoolean("retryable", retryable) + } + + private fun OmsSdkErrorCode.bridgeCode(): String { + val words = name.replace(Regex("([a-z])([A-Z])"), "$1_$2") + return "OMS_${words.uppercase()}" } companion object { diff --git a/examples/sdk-example/ios/Podfile.lock b/examples/sdk-example/ios/Podfile.lock index 1a54987..dea25e0 100644 --- a/examples/sdk-example/ios/Podfile.lock +++ b/examples/sdk-example/ios/Podfile.lock @@ -3,10 +3,10 @@ PODS: - hermes-engine (250829098.0.10): - hermes-engine/Pre-built (= 250829098.0.10) - hermes-engine/Pre-built (250829098.0.10) - - oms-client-swift-sdk (0.1.0-alpha.1) + - oms-client-swift-sdk (0.1.0-alpha.2) - OmsClientReactNativeSdk (0.1.0-alpha.1): - hermes-engine - - oms-client-swift-sdk (= 0.1.0-alpha.1) + - oms-client-swift-sdk (= 0.1.0-alpha.2) - RCTRequired - RCTTypeSafety - React-Core @@ -2056,81 +2056,81 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d hermes-engine: 050bf0e0b60ac8f6b274d047bab7d63320f019cc - oms-client-swift-sdk: 3ebf1d3e0668616b7c0df697c4c87ca82df5bf35 - OmsClientReactNativeSdk: db42bf9bff2bc38c2294af4c955bd40825843852 + oms-client-swift-sdk: 6e4663b82d54091c97e7670caf5d3c2d40fdbad3 + OmsClientReactNativeSdk: f54b5bd0f5290070984706a6a70cb8052b5f9395 RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12 RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702 - RCTSwiftUIWrapper: 7b8a1ffba8994a8f955c563511bffb74b4681317 + RCTSwiftUIWrapper: 966ca7f5f22ac0b2b2255fb09cffc381f5440b03 RCTTypeSafety: 2a6403ba3492c04510e7c15bd635461646c43bb2 React: e2dc35338068bbd299c66f043ae0d7f25de8499e React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48 - React-Core: f90d375d3bab515ad00df30605ce1bf02e6db12f + React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0 React-Core-prebuilt: bd567b119737176084436942e59dc87cd8b0c7f7 - React-CoreModules: da4f80202ef954bdfb926ca4c9ac5f685900aa5c - React-cxxreact: 1d1d2a28a5f10e4e2e7cf2bfd54dc9c92c68c672 + React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146 + React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716 React-debug: 92944dc4d89f56d640e75498266cbde557a48189 - React-defaultsnativemodule: 172d2ef7d11c531c40ee74f3be1ac98d258a817f - React-domnativemodule: 5a60a88075a35e20fa5879c94e7aa24e5f4071d0 - React-Fabric: 5c1954e06c094623e46d51da2dc2c926621c03a5 - React-FabricComponents: f32494150868073accd5d52d46b30a0496626813 - React-FabricImage: dfcd2826b10f05c19e5bfa68d4937c4401c129db - React-featureflags: 84bcfcb8f91f9475e437f3dd579399085a4af51e - React-featureflagsnativemodule: 83111c4ee188b8859aaa8643c1be640442098fef - React-graphics: e0441bc695f5c682a3fb1b0fd317e10765700f12 - React-hermes: da795ff5d5816d8940fca927c46dcdd81d7e9ad6 - React-idlecallbacksnativemodule: b6d79e1c9e335564e17d18e2649fe7524c4e74e4 - React-ImageManager: 0e137d7231092ddaa236f3520b67b4aa833e7079 - React-intersectionobservernativemodule: 160b3c85bb5a3df2bf50e60619c0db50aadbef8c - React-jserrorhandler: 619d382632f5ed166541c03f7ba7deee76fb1b16 - React-jsi: eac528e72d25146faa922ef1aad82d338addbb75 - React-jsiexecutor: 2d3000fdde95d0da451f8976cdf9018448756023 - React-jsinspector: 0377987f9635c64c5aaf72ec73aaf2b0b0da7744 - React-jsinspectorcdp: 9db641a9cd0dd5b693df27d2e665ac2540ddc336 - React-jsinspectornetwork: 61b00d0adfe6f175830660f1b98da576bc74eb0a - React-jsinspectortracing: 0f8d4f2ebd7523aece78cbc926cd4b6f2fc227dc - React-jsitooling: 36f3854063d507bc4d4a01227ebf1b63d9e24592 - React-jsitracing: cddf044c0c2847d3f5822a984018fd5596c1d448 - React-logger: 57001aefabd79d885589d2f31a8be05ccc393eaa - React-Mapbuffer: 1aa9126122d4247ffc24bf9d28d50ce923499a71 - React-microtasksnativemodule: d86581169e9bb5bb6f5fc3c5052f890016c1bf21 - React-mutationobservernativemodule: 9a0c4e866f1ef2a57acebc902ebacbdf469ae741 - React-NativeModulesApple: cc6ec4767844d610e92cc358bd3ea34937438d56 - React-networking: a8ce15641ed7775d5b54a9d0d32defc367c2216e + React-defaultsnativemodule: cd64bc09d7ca24112bbaf1b91edbbcf3d81ea7dc + React-domnativemodule: dacf5bc055ae041039574f38b73a20b91e368774 + React-Fabric: bb0baa33d91839631d315800eb23e9aaa4338a44 + React-FabricComponents: c504d0b0e2f3054b2ba19af839f175cb361153c0 + React-FabricImage: 91eaea1cc58d25ae2596a9277bcfe028f92374c3 + React-featureflags: 5ac0455da0af12ca79b40402e2f42c5c7556b638 + React-featureflagsnativemodule: df7da181b064f10f5959a7cd529b5aab3686ecb2 + React-graphics: d25b1195baf24c7918543f4aff9be89cc080906f + React-hermes: 663286153a8ad6bf752b742654c766ff5e8991e7 + React-idlecallbacksnativemodule: 0bd5392cb67f1ab25df736814b7c05213b1d3c68 + React-ImageManager: a03eed3e3d4222130dc0ad503a1a5f3aa89de746 + React-intersectionobservernativemodule: 5d0f1c3c7b30031b0f6f730ee52dd9d607e2c966 + React-jserrorhandler: d5d6e7e20c5a2d6e8607e18d31d8712d7de676f6 + React-jsi: eb7cc4cffcf24796cc302d5b2bca0e92544139a9 + React-jsiexecutor: 65006f60e64c72c6b82f62ef6bd17c84846e73f8 + React-jsinspector: 01e32e2247b2486117fbb143db7f7717ef462c6d + React-jsinspectorcdp: f1cbb34ca41d188ba22efd9b663cf258a911f6cd + React-jsinspectornetwork: f61acc94c881c41451f508abfe6efa748b956c21 + React-jsinspectortracing: 9395894d9bd4d17931b9afcf230c0e7f4cb3d674 + React-jsitooling: 03ead841daa12a93b18479f5e400ceab3732d36d + React-jsitracing: 4ae61c79e14360d1c6ab566031c62c490da78439 + React-logger: bf149dea4343a9037b74bade36cced8b63f03f46 + React-Mapbuffer: fec3e025f0ffba6b32cd2a1d7bbdee3e269aae90 + React-microtasksnativemodule: ab33a818d339f5a1da308893c11b487be66121a8 + React-mutationobservernativemodule: a42d1626651ccd7d0dc02a56e69d4ec77c248893 + React-NativeModulesApple: deba264b03bd79c6bd61014fa30e40321b5e443a + React-networking: 35e6070b084f435429f85c5db40b4d5b38652fe9 React-oscompat: 64a0c7ef5441855dc6e2a6afe8ba8f92aa05075e - React-perflogger: b2b0144e4ba8dd157c1e90f8ebb02d22edbd040d - React-performancecdpmetrics: 5a715ec33f2dea48a93a70db37a78b4f51f9fd5a - React-performancetimeline: c292077a6fbc7f423269b01aab0fb7cc48efe3c0 + React-perflogger: ca04287f205086a1edb5c95882be7b6068458889 + React-performancecdpmetrics: cf1d0a3178ccd59353cedcacbda421f40100a889 + React-performancetimeline: c9771212e7a43032d6f8d5edfb58280d46a7ce1f React-RCTActionSheet: ab545c1e7b5f1ce4f8b40b6fa06afe2869095884 - React-RCTAnimation: 1f9a58c7b74c25ea4ca6e6fbd05f37cc4919097a - React-RCTAppDelegate: 856f82c57b47c1795009af585cfb7b87fe2d3b5a - React-RCTBlob: 1cb18861a19be0b4d7e44bed9315e791413399f9 - React-RCTFabric: 081853d9efb9b38fa868c1ff3d60e48106fe2a24 - React-RCTFBReactNativeSpec: cbc760c7a889bfce20746ca0037b97c10ea58665 - React-RCTImage: 0d94b22644ca87fcb778a8fa3ffbf990bc9028df - React-RCTLinking: 881a0c6772dd05a14ab0edfea05dadb698ec8090 - React-RCTNetwork: feb412861e3fff3426eb0961c2acdd96bee8dce1 - React-RCTRuntime: c61b848ffadc0209fe643406370d58021bd3585d - React-RCTSettings: b6e14b8764f807ebe9be2e7f0d72be83b359ac26 - React-RCTText: 1ecd4f85ff609183ebec858cf94c0f27a9dec163 - React-RCTVibration: 3611aa5cb59094632b3141d72c50cbb8ce3d5b08 + React-RCTAnimation: 343147a9cd68c93d0ca280799fedfc7102d76ce4 + React-RCTAppDelegate: 5054754e92aaa9f8bfabe0f1022b84e46f3dcb57 + React-RCTBlob: 8c7ae3422ca4e72bc64b7a0142fd730efc5d4dfb + React-RCTFabric: be458db054b206c4d8e4f20f666e75d5f2c2d420 + React-RCTFBReactNativeSpec: 06db2e8d0f352d9fa23321aed1dd2cde25a3e83c + React-RCTImage: 481457bca63e039eb997f7d16c7560472f49657e + React-RCTLinking: 76cbb871240cec2dc5e7aa26c60f59e0ebbcf5a2 + React-RCTNetwork: e23a778225b7672e38545d3c5c24e1f4aad6d15f + React-RCTRuntime: 93c830b3ab3f7b494bbe7ae7289784f8b07b3947 + React-RCTSettings: 96196b535bef147381f96cc60ce9bda85d8be848 + React-RCTText: 749ebbd1a999fd84d80f37002ea3bf597fcea6df + React-RCTVibration: 5b41a7f274757c2928845981d970916ef9e4ca14 React-rendererconsistency: 6708acd4bc39c1c5b00164370d0010d93b324c1f - React-renderercss: d4a70898381431dcc07d6deba94a7eaef0cc9058 - React-rendererdebug: ad92235a960983f69183e2e59e2bce21e72c80a0 - React-RuntimeApple: 53a5b8fe60b044ec6fd4d254d66b7da69314a295 - React-RuntimeCore: 97e125471c0b8313db2bbeb1e9dc001a5503e979 - React-runtimeexecutor: 0ff42dcf1cc4bc7a89d29532a16a7d2d3849fb2f - React-RuntimeHermes: da5a1b7e39f3db1a7b3303d75d4c5bb6cd7c0d49 - React-runtimescheduler: 0e24c80f0c8b6e822a33e4ad89e0ab555704eedb - React-timing: f23e82ad46673716e34a786048414ab80f237594 - React-utils: 2851415c698da4b18331f9a445825d260fffa32c - React-webperformancenativemodule: 9f29ed34f6f6c01dac688b95fd2dfcbccf3aed7a - ReactAppDependencyProvider: a54c0c9b976766e1b6d5e2bb4d1ad0d15913e9e1 - ReactCodegen: 9af5798e943781fd2318dab7b60f25337388047c - ReactCommon: 3ec2a55999d296af90c24beb66c8a77dc660d3ef + React-renderercss: 80eb778756fed511d6128fc005188ac9008b0baf + React-rendererdebug: b46f338fb9d3f0bea6cf0621016c6c5a7a18e72e + React-RuntimeApple: c494b2089fad4a0c553cf63c2bb265f7eca2285d + React-RuntimeCore: b0bb151c3e2b26c6309d45d05c54aa65a6e0c094 + React-runtimeexecutor: 00b18635b6216a1708f6eb35dbadfd993ec91c7b + React-RuntimeHermes: bb44c4c574ce1b9507cad2e6be015344d18b94a9 + React-runtimescheduler: e1631e57209cb94b3efc29002b6a049cac3f6599 + React-timing: 356b88317ca60d373b0d94b6e7a71b0a572899f5 + React-utils: ccc01da318979af773259c4f6cdb1876f6f86f1a + React-webperformancenativemodule: f8b97c2cb6cfa94e92a503c09ad6d491c50a1390 + ReactAppDependencyProvider: 25c9c516839be2c5e3d3344f95dc7da5f7e63fc2 + ReactCodegen: c8f81e6c6f762dcf442a6203a1fb58f7dafc8014 + ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f ReactNativeDependencies: aa020a3b32eb01c8ad2837b6b1d592b9d4f3ba85 - RNInAppBrowser: b53e6f6072c931115bc22ac9dc9510ac2cbea62d - Yoga: 36fee8f1fca3f54a28c3d7f80e69f66d73d3af96 + RNInAppBrowser: 904d24dc75e8e6c6c98a3160329192608946f9df + Yoga: 77dfa8673de2874e1855002ae59c68b8be9b007b PODFILE CHECKSUM: 287c20e8b3351f7d564be9fd7544afe469845e93 diff --git a/examples/trails-actions-example/ios/Podfile.lock b/examples/trails-actions-example/ios/Podfile.lock index 8911361..16ff805 100644 --- a/examples/trails-actions-example/ios/Podfile.lock +++ b/examples/trails-actions-example/ios/Podfile.lock @@ -3,10 +3,10 @@ PODS: - hermes-engine (250829098.0.10): - hermes-engine/Pre-built (= 250829098.0.10) - hermes-engine/Pre-built (250829098.0.10) - - oms-client-swift-sdk (0.1.0-alpha.1) + - oms-client-swift-sdk (0.1.0-alpha.2) - OmsClientReactNativeSdk (0.1.0-alpha.1): - hermes-engine - - oms-client-swift-sdk (= 0.1.0-alpha.1) + - oms-client-swift-sdk (= 0.1.0-alpha.2) - RCTRequired - RCTTypeSafety - React-Core @@ -2081,82 +2081,82 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d hermes-engine: 90b235deeeedbd59b4f8ea505512fe1c61b464f7 - oms-client-swift-sdk: 3ebf1d3e0668616b7c0df697c4c87ca82df5bf35 - OmsClientReactNativeSdk: db42bf9bff2bc38c2294af4c955bd40825843852 + oms-client-swift-sdk: 6e4663b82d54091c97e7670caf5d3c2d40fdbad3 + OmsClientReactNativeSdk: f54b5bd0f5290070984706a6a70cb8052b5f9395 RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12 RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702 - RCTSwiftUIWrapper: 7b8a1ffba8994a8f955c563511bffb74b4681317 + RCTSwiftUIWrapper: 966ca7f5f22ac0b2b2255fb09cffc381f5440b03 RCTTypeSafety: 2a6403ba3492c04510e7c15bd635461646c43bb2 React: e2dc35338068bbd299c66f043ae0d7f25de8499e React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48 - React-Core: f90d375d3bab515ad00df30605ce1bf02e6db12f + React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0 React-Core-prebuilt: fdd2cc5551598e6cd3fc13aa50a66b922aaa1e20 - React-CoreModules: da4f80202ef954bdfb926ca4c9ac5f685900aa5c - React-cxxreact: 1d1d2a28a5f10e4e2e7cf2bfd54dc9c92c68c672 + React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146 + React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716 React-debug: 92944dc4d89f56d640e75498266cbde557a48189 - React-defaultsnativemodule: 172d2ef7d11c531c40ee74f3be1ac98d258a817f - React-domnativemodule: 5a60a88075a35e20fa5879c94e7aa24e5f4071d0 - React-Fabric: 5c1954e06c094623e46d51da2dc2c926621c03a5 - React-FabricComponents: f32494150868073accd5d52d46b30a0496626813 - React-FabricImage: dfcd2826b10f05c19e5bfa68d4937c4401c129db - React-featureflags: 84bcfcb8f91f9475e437f3dd579399085a4af51e - React-featureflagsnativemodule: 83111c4ee188b8859aaa8643c1be640442098fef - React-graphics: e0441bc695f5c682a3fb1b0fd317e10765700f12 - React-hermes: da795ff5d5816d8940fca927c46dcdd81d7e9ad6 - React-idlecallbacksnativemodule: b6d79e1c9e335564e17d18e2649fe7524c4e74e4 - React-ImageManager: 0e137d7231092ddaa236f3520b67b4aa833e7079 - React-intersectionobservernativemodule: 160b3c85bb5a3df2bf50e60619c0db50aadbef8c - React-jserrorhandler: 619d382632f5ed166541c03f7ba7deee76fb1b16 - React-jsi: eac528e72d25146faa922ef1aad82d338addbb75 - React-jsiexecutor: 2d3000fdde95d0da451f8976cdf9018448756023 - React-jsinspector: 0377987f9635c64c5aaf72ec73aaf2b0b0da7744 - React-jsinspectorcdp: 9db641a9cd0dd5b693df27d2e665ac2540ddc336 - React-jsinspectornetwork: 61b00d0adfe6f175830660f1b98da576bc74eb0a - React-jsinspectortracing: 0f8d4f2ebd7523aece78cbc926cd4b6f2fc227dc - React-jsitooling: 36f3854063d507bc4d4a01227ebf1b63d9e24592 - React-jsitracing: cddf044c0c2847d3f5822a984018fd5596c1d448 - React-logger: 57001aefabd79d885589d2f31a8be05ccc393eaa - React-Mapbuffer: 1aa9126122d4247ffc24bf9d28d50ce923499a71 - React-microtasksnativemodule: d86581169e9bb5bb6f5fc3c5052f890016c1bf21 - React-mutationobservernativemodule: 9a0c4e866f1ef2a57acebc902ebacbdf469ae741 - react-native-webview: 474aeef432561cbd160483cc48796a212f4a9e22 - React-NativeModulesApple: cc6ec4767844d610e92cc358bd3ea34937438d56 - React-networking: a8ce15641ed7775d5b54a9d0d32defc367c2216e + React-defaultsnativemodule: cd64bc09d7ca24112bbaf1b91edbbcf3d81ea7dc + React-domnativemodule: dacf5bc055ae041039574f38b73a20b91e368774 + React-Fabric: bb0baa33d91839631d315800eb23e9aaa4338a44 + React-FabricComponents: c504d0b0e2f3054b2ba19af839f175cb361153c0 + React-FabricImage: 91eaea1cc58d25ae2596a9277bcfe028f92374c3 + React-featureflags: 5ac0455da0af12ca79b40402e2f42c5c7556b638 + React-featureflagsnativemodule: df7da181b064f10f5959a7cd529b5aab3686ecb2 + React-graphics: d25b1195baf24c7918543f4aff9be89cc080906f + React-hermes: 663286153a8ad6bf752b742654c766ff5e8991e7 + React-idlecallbacksnativemodule: 0bd5392cb67f1ab25df736814b7c05213b1d3c68 + React-ImageManager: a03eed3e3d4222130dc0ad503a1a5f3aa89de746 + React-intersectionobservernativemodule: 5d0f1c3c7b30031b0f6f730ee52dd9d607e2c966 + React-jserrorhandler: d5d6e7e20c5a2d6e8607e18d31d8712d7de676f6 + React-jsi: eb7cc4cffcf24796cc302d5b2bca0e92544139a9 + React-jsiexecutor: 65006f60e64c72c6b82f62ef6bd17c84846e73f8 + React-jsinspector: 01e32e2247b2486117fbb143db7f7717ef462c6d + React-jsinspectorcdp: f1cbb34ca41d188ba22efd9b663cf258a911f6cd + React-jsinspectornetwork: f61acc94c881c41451f508abfe6efa748b956c21 + React-jsinspectortracing: 9395894d9bd4d17931b9afcf230c0e7f4cb3d674 + React-jsitooling: 03ead841daa12a93b18479f5e400ceab3732d36d + React-jsitracing: 4ae61c79e14360d1c6ab566031c62c490da78439 + React-logger: bf149dea4343a9037b74bade36cced8b63f03f46 + React-Mapbuffer: fec3e025f0ffba6b32cd2a1d7bbdee3e269aae90 + React-microtasksnativemodule: ab33a818d339f5a1da308893c11b487be66121a8 + React-mutationobservernativemodule: a42d1626651ccd7d0dc02a56e69d4ec77c248893 + react-native-webview: 2da09bdea1aeb6c46e27dd94632e21059cade6c1 + React-NativeModulesApple: deba264b03bd79c6bd61014fa30e40321b5e443a + React-networking: 35e6070b084f435429f85c5db40b4d5b38652fe9 React-oscompat: 64a0c7ef5441855dc6e2a6afe8ba8f92aa05075e - React-perflogger: b2b0144e4ba8dd157c1e90f8ebb02d22edbd040d - React-performancecdpmetrics: 5a715ec33f2dea48a93a70db37a78b4f51f9fd5a - React-performancetimeline: c292077a6fbc7f423269b01aab0fb7cc48efe3c0 + React-perflogger: ca04287f205086a1edb5c95882be7b6068458889 + React-performancecdpmetrics: cf1d0a3178ccd59353cedcacbda421f40100a889 + React-performancetimeline: c9771212e7a43032d6f8d5edfb58280d46a7ce1f React-RCTActionSheet: ab545c1e7b5f1ce4f8b40b6fa06afe2869095884 - React-RCTAnimation: 1f9a58c7b74c25ea4ca6e6fbd05f37cc4919097a - React-RCTAppDelegate: 856f82c57b47c1795009af585cfb7b87fe2d3b5a - React-RCTBlob: 1cb18861a19be0b4d7e44bed9315e791413399f9 - React-RCTFabric: 081853d9efb9b38fa868c1ff3d60e48106fe2a24 - React-RCTFBReactNativeSpec: cbc760c7a889bfce20746ca0037b97c10ea58665 - React-RCTImage: 0d94b22644ca87fcb778a8fa3ffbf990bc9028df - React-RCTLinking: 881a0c6772dd05a14ab0edfea05dadb698ec8090 - React-RCTNetwork: feb412861e3fff3426eb0961c2acdd96bee8dce1 - React-RCTRuntime: c61b848ffadc0209fe643406370d58021bd3585d - React-RCTSettings: b6e14b8764f807ebe9be2e7f0d72be83b359ac26 - React-RCTText: 1ecd4f85ff609183ebec858cf94c0f27a9dec163 - React-RCTVibration: 3611aa5cb59094632b3141d72c50cbb8ce3d5b08 + React-RCTAnimation: 343147a9cd68c93d0ca280799fedfc7102d76ce4 + React-RCTAppDelegate: 5054754e92aaa9f8bfabe0f1022b84e46f3dcb57 + React-RCTBlob: 8c7ae3422ca4e72bc64b7a0142fd730efc5d4dfb + React-RCTFabric: be458db054b206c4d8e4f20f666e75d5f2c2d420 + React-RCTFBReactNativeSpec: 06db2e8d0f352d9fa23321aed1dd2cde25a3e83c + React-RCTImage: 481457bca63e039eb997f7d16c7560472f49657e + React-RCTLinking: 76cbb871240cec2dc5e7aa26c60f59e0ebbcf5a2 + React-RCTNetwork: e23a778225b7672e38545d3c5c24e1f4aad6d15f + React-RCTRuntime: 93c830b3ab3f7b494bbe7ae7289784f8b07b3947 + React-RCTSettings: 96196b535bef147381f96cc60ce9bda85d8be848 + React-RCTText: 749ebbd1a999fd84d80f37002ea3bf597fcea6df + React-RCTVibration: 5b41a7f274757c2928845981d970916ef9e4ca14 React-rendererconsistency: 6708acd4bc39c1c5b00164370d0010d93b324c1f - React-renderercss: d4a70898381431dcc07d6deba94a7eaef0cc9058 - React-rendererdebug: ad92235a960983f69183e2e59e2bce21e72c80a0 - React-RuntimeApple: 53a5b8fe60b044ec6fd4d254d66b7da69314a295 - React-RuntimeCore: 97e125471c0b8313db2bbeb1e9dc001a5503e979 - React-runtimeexecutor: 0ff42dcf1cc4bc7a89d29532a16a7d2d3849fb2f - React-RuntimeHermes: da5a1b7e39f3db1a7b3303d75d4c5bb6cd7c0d49 - React-runtimescheduler: 0e24c80f0c8b6e822a33e4ad89e0ab555704eedb - React-timing: f23e82ad46673716e34a786048414ab80f237594 - React-utils: 2851415c698da4b18331f9a445825d260fffa32c - React-webperformancenativemodule: 9f29ed34f6f6c01dac688b95fd2dfcbccf3aed7a - ReactAppDependencyProvider: a54c0c9b976766e1b6d5e2bb4d1ad0d15913e9e1 - ReactCodegen: 9af5798e943781fd2318dab7b60f25337388047c - ReactCommon: 3ec2a55999d296af90c24beb66c8a77dc660d3ef + React-renderercss: 80eb778756fed511d6128fc005188ac9008b0baf + React-rendererdebug: b46f338fb9d3f0bea6cf0621016c6c5a7a18e72e + React-RuntimeApple: c494b2089fad4a0c553cf63c2bb265f7eca2285d + React-RuntimeCore: b0bb151c3e2b26c6309d45d05c54aa65a6e0c094 + React-runtimeexecutor: 00b18635b6216a1708f6eb35dbadfd993ec91c7b + React-RuntimeHermes: bb44c4c574ce1b9507cad2e6be015344d18b94a9 + React-runtimescheduler: e1631e57209cb94b3efc29002b6a049cac3f6599 + React-timing: 356b88317ca60d373b0d94b6e7a71b0a572899f5 + React-utils: ccc01da318979af773259c4f6cdb1876f6f86f1a + React-webperformancenativemodule: f8b97c2cb6cfa94e92a503c09ad6d491c50a1390 + ReactAppDependencyProvider: 25c9c516839be2c5e3d3344f95dc7da5f7e63fc2 + ReactCodegen: c8f81e6c6f762dcf442a6203a1fb58f7dafc8014 + ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f ReactNativeDependencies: 9621027d00b5054d12f214da6fc23438e4e00280 - RNInAppBrowser: b53e6f6072c931115bc22ac9dc9510ac2cbea62d - Yoga: 36fee8f1fca3f54a28c3d7f80e69f66d73d3af96 + RNInAppBrowser: 904d24dc75e8e6c6c98a3160329192608946f9df + Yoga: 77dfa8673de2874e1855002ae59c68b8be9b007b PODFILE CHECKSUM: ba0252cbbc5c3feadec41532dd2f28c982104d60 diff --git a/ios/OmsClientReactNativeSdk.mm b/ios/OmsClientReactNativeSdk.mm index a2d506e..0c74471 100644 --- a/ios/OmsClientReactNativeSdk.mm +++ b/ios/OmsClientReactNativeSdk.mm @@ -23,6 +23,12 @@ - (instancetype)init [strongSelf emitOnFeeOptionSelectionRequest:payload]; } }]; + [_impl setSessionExpiredEventEmitter:^(NSDictionary *payload) { + OmsClientReactNativeSdk *strongSelf = weakSelf; + if (strongSelf) { + [strongSelf emitOnSessionExpired:payload]; + } + }]; } return self; } @@ -72,12 +78,14 @@ - (void)startEmailAuth:(NSString *)email - (void)completeEmailAuth:(NSString *)code walletSelection:(nullable NSString *)walletSelection walletType:(nullable NSString *)walletType + sessionLifetimeSeconds:(nullable NSString *)sessionLifetimeSeconds resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [_impl completeEmailAuthWithCode:code walletSelection:walletSelection walletType:walletType + sessionLifetimeSeconds:sessionLifetimeSeconds resolve:resolve reject:reject]; } @@ -87,6 +95,7 @@ - (void)signInWithOidcIdToken:(NSString *)idToken audience:(NSString *)audience walletSelection:(nullable NSString *)walletSelection walletType:(nullable NSString *)walletType + sessionLifetimeSeconds:(nullable NSString *)sessionLifetimeSeconds resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { @@ -95,6 +104,7 @@ - (void)signInWithOidcIdToken:(NSString *)idToken audience:audience walletSelection:walletSelection walletType:walletType + sessionLifetimeSeconds:sessionLifetimeSeconds resolve:resolve reject:reject]; } @@ -104,6 +114,7 @@ - (void)startOidcRedirectAuth:(NSString *)providerJson walletType:(nullable NSString *)walletType relayRedirectUri:(nullable NSString *)relayRedirectUri authorizeParamsJson:(nullable NSString *)authorizeParamsJson + loginHint:(nullable NSString *)loginHint resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { @@ -112,17 +123,20 @@ - (void)startOidcRedirectAuth:(NSString *)providerJson walletType:walletType relayRedirectUri:relayRedirectUri authorizeParamsJson:authorizeParamsJson + loginHint:loginHint resolve:resolve reject:reject]; } - (void)handleOidcRedirectCallback:(nullable NSString *)callbackUrl walletSelection:(nullable NSString *)walletSelection + sessionLifetimeSeconds:(nullable NSString *)sessionLifetimeSeconds resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [_impl handleOidcRedirectCallbackWithCallbackUrl:callbackUrl walletSelection:walletSelection + sessionLifetimeSeconds:sessionLifetimeSeconds resolve:resolve reject:reject]; } diff --git a/ios/OmsClientReactNativeSdkImpl.swift b/ios/OmsClientReactNativeSdkImpl.swift index d71f0a9..9d17ea8 100644 --- a/ios/OmsClientReactNativeSdkImpl.swift +++ b/ios/OmsClientReactNativeSdkImpl.swift @@ -5,18 +5,24 @@ import React @objc(OmsClientReactNativeSdkImpl) public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { private var client: OMSClient? - private var tokenBalancesIndexer: OmsBridgeTokenBalancesIndexer? private var feeOptionSelectionRequestEmitter: ((NSDictionary) -> Void)? + private var sessionExpiredEventEmitter: ((NSDictionary) -> Void)? private var pendingFeeOptionSelections: [String: CheckedContinuation] = [:] private let pendingFeeOptionSelectionsLock = NSLock() private var pendingWalletSelections: [String: PendingWalletSelection] = [:] private let pendingWalletSelectionsLock = NSLock() + private static let defaultSessionLifetimeSeconds: UInt32 = 604_800 @objc(setFeeOptionSelectionRequestEmitter:) public func setFeeOptionSelectionRequestEmitter(_ emitter: @escaping (NSDictionary) -> Void) { feeOptionSelectionRequestEmitter = emitter } + @objc(setSessionExpiredEventEmitter:) + public func setSessionExpiredEventEmitter(_ emitter: @escaping (NSDictionary) -> Void) { + sessionExpiredEventEmitter = emitter + } + @objc(configureWithPublishableKey:walletApiUrl:apiRpcUrl:indexerUrlTemplate:projectId:resolve:reject:) public func configure( publishableKey: String, @@ -34,15 +40,17 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { ) clearPendingWalletSelections() - tokenBalancesIndexer = OmsBridgeTokenBalancesIndexer( - publishableKey: publishableKey, - indexerUrlTemplate: environment.indexerUrlTemplate - ) client = OMSClient( - projectAccessKey: publishableKey, + publishableKey: publishableKey, projectId: projectId, environment: environment ) + client?.wallet.onSessionExpired = { [weak self] event in + guard let self else { + return + } + self.sessionExpiredEventEmitter?(self.sessionExpiredEventDictionary(event) as NSDictionary) + } resolve(nil) } @@ -86,11 +94,12 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { } } - @objc(completeEmailAuthWithCode:walletSelection:walletType:resolve:reject:) + @objc(completeEmailAuthWithCode:walletSelection:walletType:sessionLifetimeSeconds:resolve:reject:) public func completeEmailAuth( code: String, walletSelection: String?, walletType: String?, + sessionLifetimeSeconds: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { @@ -98,19 +107,21 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { let result = try await client.wallet.completeEmailAuth( code: code, walletSelection: try self.walletSelectionBehavior(walletSelection), - walletType: try self.walletType(walletType) + walletType: try self.walletType(walletType), + sessionLifetimeSeconds: try self.sessionLifetimeSeconds(sessionLifetimeSeconds) ) return try self.completeAuthResultDictionary(result) } } - @objc(signInWithOidcIdTokenWithIdToken:issuer:audience:walletSelection:walletType:resolve:reject:) + @objc(signInWithOidcIdTokenWithIdToken:issuer:audience:walletSelection:walletType:sessionLifetimeSeconds:resolve:reject:) public func signInWithOidcIdToken( idToken: String, issuer: String, audience: String, walletSelection: String?, walletType: String?, + sessionLifetimeSeconds: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { @@ -120,19 +131,21 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { issuer: issuer, audience: audience, walletType: try self.walletType(walletType), - walletSelection: try self.walletSelectionBehavior(walletSelection) + walletSelection: try self.walletSelectionBehavior(walletSelection), + sessionLifetimeSeconds: try self.sessionLifetimeSeconds(sessionLifetimeSeconds) ) return try self.completeAuthResultDictionary(result) } } - @objc(startOidcRedirectAuthWithProviderJson:redirectUri:walletType:relayRedirectUri:authorizeParamsJson:resolve:reject:) + @objc(startOidcRedirectAuthWithProviderJson:redirectUri:walletType:relayRedirectUri:authorizeParamsJson:loginHint:resolve:reject:) public func startOidcRedirectAuth( providerJson: String, redirectUri: String, walletType: String?, relayRedirectUri: String?, authorizeParamsJson: String?, + loginHint: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { @@ -142,6 +155,7 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { redirectUri: redirectUri, walletType: try self.walletType(walletType), relayRedirectUri: relayRedirectUri, + loginHint: loginHint, authorizeParams: try self.decodeStringMap(authorizeParamsJson, name: "authorizeParams") ?? [:] ) return [ @@ -152,17 +166,19 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { } } - @objc(handleOidcRedirectCallbackWithCallbackUrl:walletSelection:resolve:reject:) + @objc(handleOidcRedirectCallbackWithCallbackUrl:walletSelection:sessionLifetimeSeconds:resolve:reject:) public func handleOidcRedirectCallback( callbackUrl: String?, walletSelection: String?, + sessionLifetimeSeconds: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock ) { run(resolve: resolve, reject: reject) { client in let result = try await client.wallet.handleOidcRedirectCallback( callbackUrl, - walletSelection: try self.walletSelectionBehavior(walletSelection) + walletSelection: try self.walletSelectionBehavior(walletSelection), + sessionLifetimeSeconds: try self.sessionLifetimeSeconds(sessionLifetimeSeconds) ) switch result { case .completed(let wallet): @@ -476,26 +492,16 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { ) { run(resolve: resolve, reject: reject) { client in let network = try self.requireNetwork(client, chainId: chainId) - let result: TokenBalancesResult - - if let contractAddress, page == nil, pageSize == nil { - result = try await client.indexer.getTokenBalances( - network: network, - contractAddress: contractAddress, - walletAddress: walletAddress, - includeMetadata: includeMetadata - ) - } else { - result = try await self.getTokenBalancesFromIndexer( - network: network, - contractAddress: contractAddress, - walletAddress: walletAddress, - includeMetadata: includeMetadata, - page: page, - pageSize: pageSize + let result = try await client.indexer.getTokenBalances( + network: network, + contractAddress: contractAddress, + walletAddress: walletAddress, + includeMetadata: includeMetadata, + page: TokenBalancesPageRequest( + page: try self.uint32(page, name: "page").map { Int($0) }, + pageSize: try self.uint32(pageSize, name: "pageSize").map { Int($0) } ) - } - + ) return self.tokenBalancesResultDictionary(result) } } @@ -976,9 +982,13 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { } private func signedWalletClient(_ client: OMSClient) throws -> WaasWalletClient { + // The Swift SDK does not expose waitForStatus/statusPolling knobs yet. + // Keep RN's API stable by reusing the SDK's signed WaaS client for the + // bridge-owned prepare/execute/polling path. let mirror = Mirror(reflecting: client.wallet) for child in mirror.children { - if child.label == "signedClient", let signedClient = child.value as? WaasWalletClient { + if (child.label == "signedClient" || child.label == "_signedClient"), + let signedClient = child.value as? WaasWalletClient { return signedClient } } @@ -1048,28 +1058,6 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { return result } - private func getTokenBalancesFromIndexer( - network: Network, - contractAddress: String?, - walletAddress: String, - includeMetadata: Bool, - page: String?, - pageSize: String? - ) async throws -> TokenBalancesResult { - guard let tokenBalancesIndexer else { - throw makeError("Call configure before using the OMS client") - } - - return try await tokenBalancesIndexer.getTokenBalances( - network: network, - contractAddress: contractAddress, - walletAddress: walletAddress, - includeMetadata: includeMetadata, - page: Int(try uint32(page, name: "page") ?? 0), - pageSize: Int(try uint32(pageSize, name: "pageSize") ?? 40) - ) - } - private func requireActiveWalletAddress(_ client: OMSClient) throws -> String { let walletAddress = client.wallet.walletAddress guard !walletAddress.isEmpty else { @@ -1163,6 +1151,13 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { ] } + private func sessionExpiredEventDictionary(_ event: SessionExpiredEvent) -> [String: Any] { + [ + "session": sessionDictionary(event.session), + "expiredAt": iso8601String(event.expiredAt) + ] + } + private func sessionLoginTypeString(_ loginType: SessionLoginType) -> String { switch loginType { case .email: @@ -1218,12 +1213,80 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { dictionary["accountAddress"] = balance.accountAddress ?? NSNull() dictionary["tokenId"] = balance.tokenId ?? NSNull() dictionary["balance"] = balance.balance ?? NSNull() + dictionary["balanceUSD"] = balance.balanceUSD ?? NSNull() + dictionary["priceUSD"] = balance.priceUSD ?? NSNull() + dictionary["priceUpdatedAt"] = balance.priceUpdatedAt ?? NSNull() dictionary["blockHash"] = balance.blockHash ?? NSNull() dictionary["blockNumber"] = balance.blockNumber.map(NSNumber.init(value:)) ?? NSNull() dictionary["chainId"] = balance.chainId.map(NSNumber.init(value:)) ?? NSNull() + dictionary["uniqueCollectibles"] = balance.uniqueCollectibles ?? NSNull() + dictionary["isSummary"] = balance.isSummary.map(NSNumber.init(value:)) ?? NSNull() + dictionary["contractInfo"] = balance.contractInfo.map(tokenContractInfoDictionary) ?? NSNull() + dictionary["tokenMetadata"] = balance.tokenMetadata.map(tokenMetadataDictionary) ?? NSNull() return dictionary } + private func tokenContractInfoDictionary(_ info: TokenContractInfo) -> [String: Any] { + [ + "chainId": info.chainId.map(NSNumber.init(value:)) ?? NSNull(), + "address": info.address ?? NSNull(), + "source": info.source ?? NSNull(), + "name": info.name ?? NSNull(), + "type": info.type ?? NSNull(), + "symbol": info.symbol ?? NSNull(), + "decimals": info.decimals.map(NSNumber.init(value:)) ?? NSNull(), + "logoURI": info.logoURI ?? NSNull(), + "deployed": info.deployed.map(NSNumber.init(value:)) ?? NSNull(), + "bytecodeHash": info.bytecodeHash ?? NSNull(), + "extensions": info.extensions.map(webRPCJSONObject) ?? NSNull(), + "updatedAt": info.updatedAt ?? NSNull(), + "queuedAt": info.queuedAt ?? NSNull(), + "status": info.status ?? NSNull() + ] + } + + private func tokenMetadataDictionary(_ metadata: TokenMetadata) -> [String: Any] { + [ + "chainId": metadata.chainId.map(NSNumber.init(value:)) ?? NSNull(), + "contractAddress": metadata.contractAddress ?? NSNull(), + "tokenId": metadata.tokenId ?? NSNull(), + "source": metadata.source ?? NSNull(), + "name": metadata.name ?? NSNull(), + "description": metadata.description ?? NSNull(), + "image": metadata.image ?? NSNull(), + "video": metadata.video ?? NSNull(), + "audio": metadata.audio ?? NSNull(), + "properties": metadata.properties.map(webRPCJSONObject) ?? NSNull(), + "attributes": metadata.attributes?.map(webRPCJSONObject) ?? NSNull(), + "imageData": metadata.imageData ?? NSNull(), + "externalUrl": metadata.externalUrl ?? NSNull(), + "backgroundColor": metadata.backgroundColor ?? NSNull(), + "animationUrl": metadata.animationUrl ?? NSNull(), + "decimals": metadata.decimals.map(NSNumber.init(value:)) ?? NSNull(), + "updatedAt": metadata.updatedAt ?? NSNull(), + "assets": metadata.assets?.map(tokenMetadataAssetDictionary) ?? NSNull(), + "status": metadata.status ?? NSNull(), + "queuedAt": metadata.queuedAt ?? NSNull(), + "lastFetched": metadata.lastFetched ?? NSNull() + ] + } + + private func tokenMetadataAssetDictionary(_ asset: TokenMetadataAsset) -> [String: Any] { + [ + "id": asset.id.map(NSNumber.init(value:)) ?? NSNull(), + "collectionId": asset.collectionId.map(NSNumber.init(value:)) ?? NSNull(), + "tokenId": asset.tokenId ?? NSNull(), + "url": asset.url ?? NSNull(), + "metadataField": asset.metadataField ?? NSNull(), + "name": asset.name ?? NSNull(), + "filesize": asset.filesize.map(NSNumber.init(value:)) ?? NSNull(), + "mimeType": asset.mimeType ?? NSNull(), + "width": asset.width.map(NSNumber.init(value:)) ?? NSNull(), + "height": asset.height.map(NSNumber.init(value:)) ?? NSNull(), + "updatedAt": asset.updatedAt ?? NSNull() + ] + } + private func transactionStatusDictionary(_ result: TransactionStatusResponse) -> [String: Any] { [ "status": result.status.wireValue, @@ -1340,6 +1403,15 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { return parsed } + private func sessionLifetimeSeconds(_ value: String?) throws -> UInt32 { + let parsed = try uint32(value, name: "sessionLifetimeSeconds") + ?? Self.defaultSessionLifetimeSeconds + guard parsed > 0 else { + throw makeError("sessionLifetimeSeconds must be a positive whole number") + } + return parsed + } + private func statusPollingOptions( timeoutMs: String?, intervalMs: String?, @@ -1413,6 +1485,31 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { return try JSONDecoder().decode([AbiArg].self, from: data) } + private func webRPCJSONObject(_ value: [String: WebRPCJSONValue]) -> [String: Any] { + value.mapValues(webRPCJSONValue) + } + + private func webRPCJSONValue(_ value: WebRPCJSONValue) -> Any { + switch value { + case .object(let object): + return webRPCJSONObject(object) + case .array(let array): + return array.map(webRPCJSONValue) + case .string(let string): + return string + case .integer(let integer): + return NSNumber(value: integer) + case .unsignedInteger(let unsignedInteger): + return NSNumber(value: unsignedInteger) + case .number(let number): + return NSNumber(value: number) + case .bool(let bool): + return NSNumber(value: bool) + case .null: + return NSNull() + } + } + private func makeError(_ message: String) -> NSError { NSError( domain: "OmsClientReactNativeSdk", @@ -1422,6 +1519,30 @@ public final class OmsClientReactNativeSdkImpl: NSObject, @unchecked Sendable { } private func rejectError(_ reject: RCTPromiseRejectBlock, _ error: Error) { + if let omsError = error as? OmsSdkError { + let code = omsError.code.rawValue + var userInfo: [String: Any] = [ + NSLocalizedDescriptionKey: omsError.localizedDescription, + "code": code, + "retryable": omsError.retryable + ] + if let operation = omsError.operation { + userInfo["operation"] = operation.rawValue + } + if let status = omsError.status { + userInfo["status"] = NSNumber(value: status) + } + if let txnId = omsError.txnId { + userInfo["txnId"] = txnId + } + reject( + code, + omsError.localizedDescription, + NSError(domain: "OmsClientReactNativeSdk", code: 1, userInfo: userInfo) + ) + return + } + let nsError = error as NSError reject("oms_client_error", nsError.localizedDescription, nsError) } @@ -1448,89 +1569,6 @@ private final class PromiseCallbacks: @unchecked Sendable { } } -private final class OmsBridgeTokenBalancesIndexer: @unchecked Sendable { - private let publishableKey: String - private let indexerUrlTemplate: String - - init(publishableKey: String, indexerUrlTemplate: String) { - self.publishableKey = publishableKey - self.indexerUrlTemplate = indexerUrlTemplate - } - - func getTokenBalances( - network: Network, - contractAddress: String?, - walletAddress: String, - includeMetadata: Bool, - page: Int, - pageSize: Int - ) async throws -> TokenBalancesResult { - let request = TokenBalancesRequestBody( - page: TokenBalancesRequestPage(page: page, pageSize: pageSize, more: false), - contractAddress: contractAddress, - accountAddress: walletAddress, - includeMetadata: includeMetadata - ) - let bodyData = try JSONEncoder().encode(request) - - let baseUrl = indexerUrlTemplate.replacingOccurrences( - of: "{value}", - with: network.name - ) - let separator = baseUrl.hasSuffix("/") ? "" : "/" - guard let url = URL(string: "\(baseUrl)\(separator)GetTokenBalances") else { - throw makeError("Invalid indexer URL") - } - - var urlRequest = URLRequest(url: url) - urlRequest.httpMethod = "POST" - urlRequest.httpBody = bodyData - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - urlRequest.setValue(publishableKey, forHTTPHeaderField: "X-Access-Key") - - let (data, response) = try await URLSession.shared.data(for: urlRequest) - guard let httpResponse = response as? HTTPURLResponse else { - throw makeError("Indexer response was not an HTTP response") - } - guard (200..<300).contains(httpResponse.statusCode) else { - throw makeError("Token balances request failed with \(httpResponse.statusCode)") - } - - let payload = try JSONDecoder().decode(TokenBalancesResponseBody.self, from: data) - return TokenBalancesResult( - status: httpResponse.statusCode, - page: payload.page, - balances: payload.balances ?? [] - ) - } - - private func makeError(_ message: String) -> NSError { - NSError( - domain: "OmsClientReactNativeSdk", - code: 1, - userInfo: [NSLocalizedDescriptionKey: message] - ) - } -} - -private struct TokenBalancesRequestPage: Encodable { - let page: Int - let pageSize: Int - let more: Bool -} - -private struct TokenBalancesRequestBody: Encodable { - let page: TokenBalancesRequestPage - let contractAddress: String? - let accountAddress: String - let includeMetadata: Bool -} - -private struct TokenBalancesResponseBody: Decodable { - let page: TokenBalancesPage? - let balances: [TokenBalance]? -} - private struct OmsBridgeTransactionStatusPollingOptions: Sendable { static let defaultOptions = OmsBridgeTransactionStatusPollingOptions( timeoutMs: 60_000, diff --git a/src/NativeOmsClientReactNativeSdk.ts b/src/NativeOmsClientReactNativeSdk.ts index 79d28b4..a502078 100644 --- a/src/NativeOmsClientReactNativeSdk.ts +++ b/src/NativeOmsClientReactNativeSdk.ts @@ -72,9 +72,71 @@ export type OmsTokenBalance = { accountAddress: string | null; tokenId: string | null; balance: string | null; + balanceUSD?: string | null; + priceUSD?: string | null; + priceUpdatedAt?: string | null; blockHash: string | null; blockNumber?: number | null; chainId?: number | null; + uniqueCollectibles?: string | null; + isSummary?: boolean | null; + contractInfo?: OmsTokenContractInfo | null; + tokenMetadata?: OmsTokenMetadata | null; +}; + +export type OmsTokenContractInfo = { + chainId?: number | null; + address?: string | null; + source?: string | null; + name?: string | null; + type?: string | null; + symbol?: string | null; + decimals?: number | null; + logoURI?: string | null; + deployed?: boolean | null; + bytecodeHash?: string | null; + extensions?: CodegenTypes.UnsafeObject | null; + updatedAt?: string | null; + queuedAt?: string | null; + status?: string | null; +}; + +export type OmsTokenMetadataAsset = { + id?: number | null; + collectionId?: number | null; + tokenId?: string | null; + url?: string | null; + metadataField?: string | null; + name?: string | null; + filesize?: number | null; + mimeType?: string | null; + width?: number | null; + height?: number | null; + updatedAt?: string | null; +}; + +export type OmsTokenMetadata = { + chainId?: number | null; + contractAddress?: string | null; + tokenId?: string | null; + source?: string | null; + name?: string | null; + description?: string | null; + image?: string | null; + video?: string | null; + audio?: string | null; + properties?: CodegenTypes.UnsafeObject | null; + attributes?: CodegenTypes.UnsafeObject[] | null; + imageData?: string | null; + externalUrl?: string | null; + backgroundColor?: string | null; + animationUrl?: string | null; + decimals?: number | null; + updatedAt?: string | null; + assets?: OmsTokenMetadataAsset[] | null; + status?: string | null; + queuedAt?: string | null; + lastFetched?: string | null; }; export type OmsTokenBalancesResult = { @@ -136,6 +198,11 @@ export type OmsCredentialInfo = { isCaller: boolean; }; +export type OmsClientSessionExpiredEvent = { + session: OmsClientSessionState; + expiredAt: string; +}; + export type OmsAccessPage = { limit: number | null; cursor: string | null; @@ -148,6 +215,7 @@ export type OmsListAccessResponse = { export interface Spec extends TurboModule { readonly onFeeOptionSelectionRequest: CodegenTypes.EventEmitter; + readonly onSessionExpired: CodegenTypes.EventEmitter; configure( publishableKey: string, walletApiUrl: string | null, @@ -162,25 +230,29 @@ export interface Spec extends TurboModule { completeEmailAuth( code: string, walletSelection: string | null, - walletType: string | null + walletType: string | null, + sessionLifetimeSeconds: string | null ): Promise; signInWithOidcIdToken( idToken: string, issuer: string, audience: string, walletSelection: string | null, - walletType: string | null + walletType: string | null, + sessionLifetimeSeconds: string | null ): Promise; startOidcRedirectAuth( providerJson: string, redirectUri: string, walletType: string | null, relayRedirectUri: string | null, - authorizeParamsJson: string | null + authorizeParamsJson: string | null, + loginHint: string | null ): Promise; handleOidcRedirectCallback( callbackUrl: string | null, - walletSelection: string | null + walletSelection: string | null, + sessionLifetimeSeconds: string | null ): Promise; listWallets(): Promise; useWallet(walletId: string): Promise; diff --git a/src/client.native.ts b/src/client.native.ts index ff30ed4..d42459b 100644 --- a/src/client.native.ts +++ b/src/client.native.ts @@ -1,6 +1,7 @@ import type { EventSubscription } from 'react-native'; import OmsClientReactNativeSdk from './NativeOmsClientReactNativeSdk'; import type { + OmsClientSessionExpiredEvent as OmsNativeClientSessionExpiredEvent, OmsFeeOptionSelectionRequest, OmsNativeCompleteAuthResult, OmsNativeOidcRedirectAuthResult, @@ -18,6 +19,7 @@ import type { ListAccessPagesParams, ListAccessParams, OmsClientConfig, + OmsClientSessionExpiredEvent, OmsClientSessionState, OmsCompleteAuthResult, OmsCredentialInfo, @@ -253,6 +255,14 @@ export function getSession(): Promise { return OmsClientReactNativeSdk.getSession() as Promise; } +export function onSessionExpired( + listener: (event: OmsClientSessionExpiredEvent) => void +): EventSubscription { + return OmsClientReactNativeSdk.onSessionExpired( + listener as (event: OmsNativeClientSessionExpiredEvent) => void + ); +} + export function getSupportedNetworks(): Promise { return OmsClientReactNativeSdk.getSupportedNetworks(); } @@ -268,7 +278,8 @@ export async function completeEmailAuth( await OmsClientReactNativeSdk.completeEmailAuth( params.code, params.walletSelection ?? null, - params.walletType ?? null + params.walletType ?? null, + stringifyOptionalNumber(params.sessionLifetimeSeconds) ) ); } @@ -282,7 +293,8 @@ export async function signInWithOidcIdToken( params.issuer, params.audience, params.walletSelection ?? null, - params.walletType ?? null + params.walletType ?? null, + stringifyOptionalNumber(params.sessionLifetimeSeconds) ) ); } @@ -295,7 +307,8 @@ export function startOidcRedirectAuth( params.redirectUri, params.walletType ?? null, resolveRelayRedirectUri(params), - stringifyOptionalJson(params.authorizeParams) + stringifyOptionalJson(params.authorizeParams), + params.loginHint ?? null ); } @@ -305,7 +318,8 @@ export async function handleOidcRedirectCallback( return hydrateOidcRedirectAuthResult( await OmsClientReactNativeSdk.handleOidcRedirectCallback( params.callbackUrl ?? null, - params.walletSelection ?? null + params.walletSelection ?? null, + stringifyOptionalNumber(params.sessionLifetimeSeconds) ) ); } diff --git a/src/client.ts b/src/client.ts index c47024d..660896c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -10,6 +10,7 @@ import type { ListAccessPagesParams, ListAccessParams, OmsClientConfig, + OmsClientSessionExpiredEvent, OmsClientSessionState, OmsCompleteAuthResult, OmsCredentialInfo, @@ -30,6 +31,7 @@ import type { VerifyMessageSignatureParams, VerifyTypedDataSignatureParams, } from './types'; +import type { EventSubscription } from 'react-native'; function unsupported(): never { throw new Error( @@ -49,6 +51,12 @@ export function getSession(): Promise { unsupported(); } +export function onSessionExpired( + _listener: (event: OmsClientSessionExpiredEvent) => void +): EventSubscription { + unsupported(); +} + export function getSupportedNetworks(): Promise { unsupported(); } diff --git a/src/index.tsx b/src/index.tsx index dfdb767..1644cb2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,6 +15,7 @@ export { listAccessPages, listAccessPage, listWallets, + onSessionExpired, revokeAccess, sendTransaction, signInWithOidcIdToken, @@ -46,6 +47,7 @@ export type { OmsAccessPage, OmsClientConfig, OmsClientEnvironment, + OmsClientSessionExpiredEvent, OmsClientSessionLoginType, OmsClientSessionState, OmsCompleteAuthResult, @@ -64,6 +66,9 @@ export type { OmsTokenBalance, OmsTokenBalancesPage, OmsTokenBalancesResult, + OmsTokenContractInfo, + OmsTokenMetadata, + OmsTokenMetadataAsset, OmsTransactionMode, OmsTransactionStatusPollingOptions, OmsTransactionStatus, diff --git a/src/types.ts b/src/types.ts index e113313..56b4f6d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,6 +20,9 @@ export type { OmsTokenBalance, OmsTokenBalancesPage, OmsTokenBalancesResult, + OmsTokenContractInfo, + OmsTokenMetadata, + OmsTokenMetadataAsset, OmsTransactionStatus, OmsWallet, OmsWalletActivationResult, @@ -38,6 +41,11 @@ export type OmsClientSessionState = { sessionEmail: string | null; }; +export type OmsClientSessionExpiredEvent = { + session: OmsClientSessionState; + expiredAt: string; +}; + export type OmsClientEnvironment = { walletApiUrl?: string; apiRpcUrl?: string; @@ -126,6 +134,7 @@ export type CompleteEmailAuthParams = { code: string; walletSelection?: OmsWalletSelectionBehavior; walletType?: OmsWalletType; + sessionLifetimeSeconds?: number | null; }; export type SignInWithOidcIdTokenParams = { @@ -134,6 +143,7 @@ export type SignInWithOidcIdTokenParams = { audience: string; walletSelection?: OmsWalletSelectionBehavior; walletType?: OmsWalletType; + sessionLifetimeSeconds?: number | null; }; export type OidcProviderConfig = { @@ -158,11 +168,13 @@ export type StartOidcRedirectAuthParams = { walletType?: OmsWalletType; relayRedirectUri?: string | null; authorizeParams?: Record | null; + loginHint?: string | null; }; export type HandleOidcRedirectCallbackParams = { callbackUrl?: string | null; walletSelection?: OmsWalletSelectionBehavior; + sessionLifetimeSeconds?: number | null; }; export type CreateWalletParams = { diff --git a/src/units.ts b/src/units.ts index dffa27a..d2baba9 100644 --- a/src/units.ts +++ b/src/units.ts @@ -45,9 +45,8 @@ export type ParseUnitsRoundingMode = 'reject' | 'nearest'; export type ParseUnitsOptions = { /** - * `reject` matches Swift and common JS parseUnits behavior. - * `nearest` matches the Kotlin SDK helper by rounding over-precision to the - * nearest base unit. + * `nearest` matches the native SDK helpers by rounding over-precision to the + * nearest base unit. Use `reject` to fail on non-zero excess precision. */ roundingMode?: ParseUnitsRoundingMode; }; @@ -55,7 +54,7 @@ export type ParseUnitsOptions = { function resolveRoundingMode( options: ParseUnitsOptions ): ParseUnitsRoundingMode { - const mode = options.roundingMode ?? 'reject'; + const mode = options.roundingMode ?? 'nearest'; if (mode !== 'reject' && mode !== 'nearest') { throw new Error(`Unsupported parseUnits rounding mode: ${String(mode)}`); } From 853d9640c9bf2e89b016de017740de3c4495a0a8 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Wed, 10 Jun 2026 16:29:32 +0300 Subject: [PATCH 2/6] feat(trails-example): handle session expiry --- examples/trails-actions-example/src/App.tsx | 98 +++++++++++++++++++-- 1 file changed, 93 insertions(+), 5 deletions(-) diff --git a/examples/trails-actions-example/src/App.tsx b/examples/trails-actions-example/src/App.tsx index 812af76..68e39ea 100644 --- a/examples/trails-actions-example/src/App.tsx +++ b/examples/trails-actions-example/src/App.tsx @@ -51,10 +51,12 @@ import { getTokenBalances, handleOidcRedirectCallback, OidcProviders, + onSessionExpired, sendTransaction, signOut, startEmailAuth, startOidcRedirectAuth, + type OmsClientSessionExpiredEvent, type OmsClientSessionState, type OmsNetwork, type OmsSendTransactionResponse, @@ -651,6 +653,13 @@ function requireText(value: string, label: string): string { return trimmed; } +function expiredSessionEmail( + event: OmsClientSessionExpiredEvent | null +): string | null { + const email = event?.session.sessionEmail?.trim(); + return email ? email : null; +} + function requireWalletAddress(address: string | null): `0x${string}` { if (!address?.startsWith('0x')) { throw new Error('Sign in before preparing a Trails action.'); @@ -1222,6 +1231,8 @@ export default function App() { const [email, setEmail] = useState(''); const [code, setCode] = useState(''); const [authStatus, setAuthStatus] = useState('Waiting for sign-in.'); + const [expiredSessionEvent, setExpiredSessionEvent] = + useState(null); const [swapPolAmount, setSwapPolAmount] = useState(DEFAULT_SWAP_POL_AMOUNT); const [depositUsdcAmount, setDepositUsdcAmount] = useState( DEFAULT_DEPOSIT_USDC_AMOUNT @@ -1288,6 +1299,7 @@ export default function App() { const nextSession = await getSession(); setSession(nextSession); if (nextSession.walletAddress) { + setExpiredSessionEvent(null); setAuthStatus('Restored persisted wallet session'); setSwapStatus('Swap status: ready to prepare.'); setDepositStatus('Deposit status: ready to prepare.'); @@ -1296,6 +1308,35 @@ export default function App() { return nextSession; }, []); + const clearExpiredSessionState = useCallback(() => { + setExpiredSessionEvent(null); + }, []); + + const handleSessionExpired = useCallback( + (event: OmsClientSessionExpiredEvent) => { + const emailHint = expiredSessionEmail(event); + + setExpiredSessionEvent(event); + setSession(SIGNED_OUT_SESSION); + setAuthStage('email'); + setCode(''); + setAuthStatus( + emailHint + ? `Wallet session expired. Sign in again as ${emailHint}.` + : 'Wallet session expired. Sign in again.' + ); + if (emailHint) { + setEmail(emailHint); + } + setBrowserUrl(null); + resetActionState(); + appendLog( + `Wallet session expired at ${event.expiredAt}: wallet=${event.session.walletAddress ?? 'none'} email=${event.session.sessionEmail ?? 'none'}` + ); + }, + [appendLog, resetActionState] + ); + const runAction = useCallback( async ( label: string, @@ -1652,6 +1693,8 @@ export default function App() { useEffect(() => { if (!sdkReady) return undefined; + const sessionExpiredSubscription = onSessionExpired(handleSessionExpired); + const handleRedirectUrl = (url: string) => { if (!isDemoOidcRedirectUrl(url)) return; @@ -1680,8 +1723,17 @@ export default function App() { appendLog(`!! ${describeError(error)}`); }); - return () => subscription.remove(); - }, [appendLog, finishOidcRedirectSignIn, runAction, sdkReady]); + return () => { + sessionExpiredSubscription.remove(); + subscription.remove(); + }; + }, [ + appendLog, + finishOidcRedirectSignIn, + handleSessionExpired, + runAction, + sdkReady, + ]); const walletAddress = session.walletAddress; const isSignedIn = walletAddress != null; @@ -1731,12 +1783,17 @@ export default function App() { runAction( 'Start email sign-in', async () => { - const normalizedEmail = requireText(email, 'Email'); + const normalizedEmail = email.trim(); + const emailForSignIn = + normalizedEmail || expiredSessionEmail(expiredSessionEvent); + if (!emailForSignIn) { + throw new Error('Email is required'); + } setAuthStatus('Requesting email code...'); - await startEmailAuth(normalizedEmail); + await startEmailAuth(emailForSignIn); setEmail(''); setAuthStage('code'); - setAuthStatus(`Code requested for ${normalizedEmail}`); + setAuthStatus(`Code requested for ${emailForSignIn}`); }, (error) => { setAuthStatus(`Sign-in error: ${describeError(error)}`); @@ -1754,6 +1811,7 @@ export default function App() { const started = await startOidcRedirectAuth({ provider: OidcProviders.google(), redirectUri: DEMO_OIDC_REDIRECT_URI, + loginHint: expiredSessionEmail(expiredSessionEvent), }); appendLog(`Google redirect auth started: state=${started.state}`); @@ -1801,6 +1859,7 @@ export default function App() { setAuthStatus('Verifying code...'); await completeEmailAuth({ code: requireText(code, 'Code') }); const nextSession = await refreshSession(); + clearExpiredSessionState(); setCode(''); setAuthStage('email'); setAuthStatus('Email login complete'); @@ -1817,6 +1876,7 @@ export default function App() { const cancelCodeStep = () => { runAction('Cancel email sign-in', async () => { await signOut(); + clearExpiredSessionState(); setAuthStage('email'); setCode(''); setAuthStatus('Email sign-in cancelled.'); @@ -1828,6 +1888,7 @@ export default function App() { const logout = () => { runAction('Sign out', async () => { await signOut(); + clearExpiredSessionState(); setSession(SIGNED_OUT_SESSION); setAuthStage('email'); setCode(''); @@ -2212,6 +2273,30 @@ export default function App() { {!isSignedIn ? ( {authStatus} + {expiredSessionEvent ? ( + + + + + + ) : null} {authStage === 'email' ? ( <> Date: Wed, 10 Jun 2026 16:29:52 +0300 Subject: [PATCH 3/6] feat(sdk-example): add session controls and fee picker --- examples/sdk-example/src/App.tsx | 369 ++++++++++++++++++++++++++++++- 1 file changed, 361 insertions(+), 8 deletions(-) diff --git a/examples/sdk-example/src/App.tsx b/examples/sdk-example/src/App.tsx index 6390749..7c95f2d 100644 --- a/examples/sdk-example/src/App.tsx +++ b/examples/sdk-example/src/App.tsx @@ -26,12 +26,16 @@ import { sendTransaction, handleOidcRedirectCallback, OidcProviders, + onSessionExpired, signMessage, signOut, startEmailAuth, startOidcRedirectAuth, verifyMessageSignature, + type OmsClientSessionExpiredEvent, type OmsClientSessionState, + type OmsFeeOptionSelection, + type OmsFeeOptionWithBalance, type OmsNetwork, type OmsPendingWalletSelection, type OmsWallet, @@ -48,6 +52,7 @@ const DEMO_ENVIRONMENT = { const DEFAULT_TRANSACTION_TO = '0xE5E8B483FfC05967FcFed58cc98D053265af6D99'; const PREFERRED_NETWORK_ORDER = ['80002', '137']; +const DEFAULT_SESSION_LIFETIME_SECONDS = '604800'; const SIGNED_OUT_SESSION: OmsClientSessionState = { walletAddress: null, expiresAt: null, @@ -237,6 +242,77 @@ function WalletSelectionOption({ ); } +function FeeOptionPickerModal({ + options, + visible, + onCancel, + onSelect, +}: { + options: OmsFeeOptionWithBalance[]; + visible: boolean; + onCancel: () => void; + onSelect: (selection: OmsFeeOptionSelection) => void; +}) { + return ( + + + + + Fee Option + + + + `${option.selection.token}-${index}` + } + renderItem={({ item }) => { + const selectable = feeOptionIsSelectable(item); + return ( + onSelect(item.selection)} + style={({ pressed }) => [ + styles.feeOption, + !selectable && styles.buttonDisabled, + pressed && selectable && styles.buttonPressed, + ]} + > + + + {feeOptionTitle(item)} + + + {feeOptionSubtitle(item)} + + + {item.selection.token} + + + + {selectable ? 'Select' : 'Insufficient'} + + + ); + }} + style={styles.networkPickerList} + /> + + + + ); +} + function NetworkPickerModal({ networks, selectedChainId, @@ -312,6 +388,11 @@ export default function App() { const [code, setCode] = useState(''); const [authStatus, setAuthStatus] = useState('Waiting for sign-in.'); const [manualWalletSelection, setManualWalletSelection] = useState(false); + const [sessionLifetimeSeconds, setSessionLifetimeSeconds] = useState( + DEFAULT_SESSION_LIFETIME_SECONDS + ); + const [expiredSessionEvent, setExpiredSessionEvent] = + useState(null); const [pendingWalletSelection, setPendingWalletSelection] = useState(null); const [message, setMessage] = useState('test'); @@ -333,8 +414,14 @@ export default function App() { const [networkPickerVisible, setNetworkPickerVisible] = useState(false); const [logLines, setLogLines] = useState(['Ready.']); const [loadingAction, setLoadingAction] = useState(null); + const [feeOptionPickerOptions, setFeeOptionPickerOptions] = useState< + OmsFeeOptionWithBalance[] + >([]); const handledRedirectUrlsRef = useRef(new Set()); const handlingRedirectUrlRef = useRef(null); + const feeOptionSelectionResolverRef = useRef< + ((selection: OmsFeeOptionSelection | null) => void) | null + >(null); const selectedNetwork = useMemo( () => @@ -347,10 +434,61 @@ export default function App() { setLogLines((current) => [...current, messageToAppend].slice(-80)); }, []); + const resolveFeeOptionSelection = useCallback( + (selection: OmsFeeOptionSelection | null) => { + const resolver = feeOptionSelectionResolverRef.current; + feeOptionSelectionResolverRef.current = null; + setFeeOptionPickerOptions([]); + resolver?.(selection); + }, + [] + ); + + const selectFeeOption = useCallback( + async ( + feeOptions: OmsFeeOptionWithBalance[] + ): Promise => { + if (feeOptions.length === 0) { + appendLog('No fee options available.'); + return null; + } + + feeOptionSelectionResolverRef.current?.(null); + setFeeOptionPickerOptions(feeOptions); + appendLog(`Fee options available: ${feeOptions.length}`); + + return new Promise((resolve) => { + feeOptionSelectionResolverRef.current = resolve; + }); + }, + [appendLog] + ); + + const chooseFeeOption = useCallback( + (selection: OmsFeeOptionSelection) => { + appendLog(`Selected fee option: ${selection.token}`); + resolveFeeOptionSelection(selection); + }, + [appendLog, resolveFeeOptionSelection] + ); + + const cancelFeeOptionSelection = useCallback(() => { + appendLog('Fee option selection cancelled.'); + resolveFeeOptionSelection(null); + }, [appendLog, resolveFeeOptionSelection]); + + useEffect(() => { + return () => { + feeOptionSelectionResolverRef.current?.(null); + feeOptionSelectionResolverRef.current = null; + }; + }, []); + const refreshSession = useCallback(async () => { const nextSession = await getSession(); setSession(nextSession); if (nextSession.walletAddress) { + setExpiredSessionEvent(null); setAuthStatus('Restored persisted wallet session'); setSignatureStatus('Signature status: ready to sign.'); setTransactionStatus('Transaction status: ready to send.'); @@ -378,10 +516,49 @@ export default function App() { [appendLog] ); + const requestedSessionLifetimeSeconds = useCallback( + () => parseSessionLifetimeSeconds(sessionLifetimeSeconds), + [sessionLifetimeSeconds] + ); + + const clearExpiredSessionState = useCallback(() => { + setExpiredSessionEvent(null); + }, []); + + const handleSessionExpired = useCallback( + (event: OmsClientSessionExpiredEvent) => { + const emailHint = expiredSessionEmail(event); + + setExpiredSessionEvent(event); + setSession(SIGNED_OUT_SESSION); + setPendingWalletSelection(null); + setCode(''); + setAuthStage('email'); + setAuthStatus( + emailHint + ? `Wallet session expired. Sign in again as ${emailHint}.` + : 'Wallet session expired. Sign in again.' + ); + if (emailHint) { + setEmail(emailHint); + } + setLastSignedMessage(null); + setLastSignature(null); + setLastTransactionHash(null); + setSignatureStatus('Signature status: waiting for reauth.'); + setTransactionStatus('Transaction status: waiting for reauth.'); + appendLog( + `Wallet session expired at ${event.expiredAt}: wallet=${event.session.walletAddress ?? 'none'} email=${event.session.sessionEmail ?? 'none'}` + ); + }, + [appendLog] + ); + const activateWallet = useCallback( async (result: OmsWalletActivationResult) => { const nextSession = await getSession(); const address = nextSession.walletAddress ?? result.walletAddress; + clearExpiredSessionState(); setPendingWalletSelection(null); setCode(''); setAuthStage('email'); @@ -395,7 +572,7 @@ export default function App() { setTransactionStatus('Transaction status: ready to send.'); appendLog(`Wallet ready: ${address}`); }, - [appendLog] + [appendLog, clearExpiredSessionState] ); const finishOidcRedirectSignIn = useCallback( @@ -408,12 +585,15 @@ export default function App() { } handlingRedirectUrlRef.current = callbackUrl; + let callbackHandled = false; try { setAuthStatus('Completing Google redirect sign-in...'); const result = await handleOidcRedirectCallback({ callbackUrl, walletSelection: manualWalletSelection ? 'manual' : 'automatic', + sessionLifetimeSeconds: requestedSessionLifetimeSeconds(), }); + callbackHandled = true; switch (result.type) { case 'completed': @@ -446,11 +626,19 @@ export default function App() { break; } } finally { - handledRedirectUrlsRef.current.add(callbackUrl); + if (callbackHandled) { + handledRedirectUrlsRef.current.add(callbackUrl); + } handlingRedirectUrlRef.current = null; } }, - [activateWallet, appendLog, manualWalletSelection, refreshSession] + [ + activateWallet, + appendLog, + manualWalletSelection, + refreshSession, + requestedSessionLifetimeSeconds, + ] ); const selectNetwork = useCallback( @@ -505,6 +693,7 @@ export default function App() { useEffect(() => { if (!sdkReady) return undefined; + const sessionExpiredSubscription = onSessionExpired(handleSessionExpired); const subscription = Linking.addEventListener('url', ({ url }) => { if (isDemoOidcRedirectUrl(url)) { runAction( @@ -537,8 +726,17 @@ export default function App() { appendLog(`!! ${describeError(error)}`); }); - return () => subscription.remove(); - }, [appendLog, finishOidcRedirectSignIn, runAction, sdkReady]); + return () => { + sessionExpiredSubscription.remove(); + subscription.remove(); + }; + }, [ + appendLog, + finishOidcRedirectSignIn, + handleSessionExpired, + runAction, + sdkReady, + ]); const walletAddress = session.walletAddress; const isSignedIn = walletAddress != null; @@ -548,13 +746,18 @@ export default function App() { runAction( 'Start email sign-in', async () => { - const normalizedEmail = requireText(email, 'Email'); + const normalizedEmail = email.trim(); setAuthStatus('Requesting email code...'); setPendingWalletSelection(null); - await startEmailAuth(normalizedEmail); + const emailForSignIn = + normalizedEmail || expiredSessionEmail(expiredSessionEvent); + if (!emailForSignIn) { + throw new Error('Email is required'); + } + await startEmailAuth(emailForSignIn); setEmail(''); setAuthStage('code'); - setAuthStatus(`Code requested for ${normalizedEmail}`); + setAuthStatus(`Code requested for ${emailForSignIn}`); }, (error) => { setAuthStatus(`Email sign-in failed: ${describeError(error)}`); @@ -570,6 +773,7 @@ export default function App() { const authResult = await completeEmailAuth({ code: requireText(code, 'Verification code'), walletSelection: manualWalletSelection ? 'manual' : 'automatic', + sessionLifetimeSeconds: requestedSessionLifetimeSeconds(), }); if (authResult.type === 'walletSelection') { @@ -598,9 +802,11 @@ export default function App() { async () => { setPendingWalletSelection(null); setAuthStatus('Opening Google redirect sign-in...'); + requestedSessionLifetimeSeconds(); const started = await startOidcRedirectAuth({ provider: OidcProviders.google(), redirectUri: DEMO_OIDC_REDIRECT_URI, + loginHint: expiredSessionEmail(expiredSessionEvent), }); appendLog(`Google redirect auth started: state=${started.state}`); @@ -644,6 +850,7 @@ export default function App() { const cancelCodeStep = () => { runAction('Cancel email code step', async () => { await signOut(); + clearExpiredSessionState(); setSession(SIGNED_OUT_SESSION); setCode(''); setPendingWalletSelection(null); @@ -685,6 +892,7 @@ export default function App() { const logout = () => { runAction('Logout', async () => { await signOut(); + clearExpiredSessionState(); setSession(SIGNED_OUT_SESSION); setAuthStage('email'); setPendingWalletSelection(null); @@ -753,6 +961,7 @@ export default function App() { chainId: network.chainId, to: requireText(transactionTo, 'Transaction destination'), value: decimalToBaseUnits(transactionValue, 18), + selectFeeOption, }); setLastTransactionHash(txResult.txnHash); setTransactionStatus( @@ -809,6 +1018,30 @@ export default function App() { <> {authStatus} + {expiredSessionEvent ? ( + + + + + + ) : null} {pendingWalletSelection ? ( Finish sign-in by selecting a wallet below. @@ -822,6 +1055,17 @@ export default function App() { setManualWalletSelection((current) => !current) } /> + + !current) } /> + Verification Code + 0} + /> setNetworkPickerVisible(false)} @@ -1071,6 +1327,68 @@ function requireText(value: string | null, label: string): string { return trimmed; } +function parseSessionLifetimeSeconds(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + if (!/^\d+$/.test(trimmed)) { + throw new Error('Session lifetime seconds must be a positive whole number'); + } + + const parsed = Number(trimmed); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error('Session lifetime seconds must be a positive whole number'); + } + + return parsed; +} + +function expiredSessionEmail( + event: OmsClientSessionExpiredEvent | null +): string | null { + const email = event?.session.sessionEmail?.trim(); + return email ? email : null; +} + +function feeOptionTitle(option: OmsFeeOptionWithBalance): string { + const symbol = feeOptionSymbol(option); + return `${symbol} fee`; +} + +function feeOptionSubtitle(option: OmsFeeOptionWithBalance): string { + const symbol = feeOptionSymbol(option); + const fee = `${option.feeOption.displayValue} ${symbol}`; + const available = option.available + ? `${option.available} ${symbol}` + : 'unknown'; + + return `Fee ${fee} · Available ${available}`; +} + +function feeOptionSymbol(option: OmsFeeOptionWithBalance): string { + return option.feeOption.token.symbol || option.selection.token; +} + +function feeOptionIsSelectable(option: OmsFeeOptionWithBalance): boolean { + const available = optionalBigInt(option.availableRaw); + const fee = optionalBigInt(option.feeOption.value); + return available == null || fee == null || available >= fee; +} + +function optionalBigInt(value: string | null | undefined): bigint | null { + if (!value) { + return null; + } + + try { + return BigInt(value); + } catch { + return null; + } +} + function formatLoginType( loginType: OmsClientSessionState['loginType'] ): string { @@ -1195,6 +1513,10 @@ const styles = StyleSheet.create({ fontWeight: '600', textTransform: 'uppercase', }, + fieldSeparator: { + backgroundColor: '#303644', + height: 1, + }, input: { backgroundColor: '#0B0D12', borderColor: '#303644', @@ -1370,6 +1692,37 @@ const styles = StyleSheet.create({ color: '#94A3B8', fontSize: 12, }, + feeOption: { + alignItems: 'center', + borderColor: '#303644', + borderRadius: 8, + borderWidth: 1, + flexDirection: 'row', + gap: 12, + justifyContent: 'space-between', + marginBottom: 10, + padding: 12, + }, + feeOptionText: { + flex: 1, + gap: 4, + }, + feeOptionTitle: { + color: '#F8FAFC', + fontSize: 15, + fontWeight: '700', + }, + feeOptionSubtitle: { + color: '#CBD5E1', + fontSize: 12, + lineHeight: 17, + }, + feeOptionToken: { + color: '#94A3B8', + fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace' }), + fontSize: 11, + lineHeight: 16, + }, status: { color: '#CBD5E1', fontSize: 13, From 7bf0458aadd9f88529a2b11a67604a9b84b3b15b Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Wed, 10 Jun 2026 16:56:09 +0300 Subject: [PATCH 4/6] fix(events): replay session expiry events in JS --- src/client.native.ts | 94 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 18 deletions(-) diff --git a/src/client.native.ts b/src/client.native.ts index d42459b..b8e00dd 100644 --- a/src/client.native.ts +++ b/src/client.native.ts @@ -86,17 +86,23 @@ function hydratePendingWalletSelection( ...pendingSelection, walletType: pendingSelection.walletType as OmsPendingWalletSelection['walletType'], - selectWallet(walletId: string) { - return OmsClientReactNativeSdk.selectWalletForPendingSelection( - pendingSelection.id, - walletId - ); + async selectWallet(walletId: string) { + const result = + await OmsClientReactNativeSdk.selectWalletForPendingSelection( + pendingSelection.id, + walletId + ); + resetSessionExpiredReplay(); + return result; }, - createAndSelectWallet(reference?: string | null) { - return OmsClientReactNativeSdk.createAndSelectWalletForPendingSelection( - pendingSelection.id, - reference ?? null - ); + async createAndSelectWallet(reference?: string | null) { + const result = + await OmsClientReactNativeSdk.createAndSelectWalletForPendingSelection( + pendingSelection.id, + reference ?? null + ); + resetSessionExpiredReplay(); + return result; }, }; } @@ -178,6 +184,11 @@ function hydrateOidcRedirectAuthResult( let nextFeeOptionSelectorId = 0; let feeOptionSelectionSubscription: EventSubscription | null = null; const feeOptionSelectors = new Map(); +let sessionExpiredSubscription: EventSubscription | null = null; +let latestSessionExpiredEvent: OmsClientSessionExpiredEvent | null = null; +const sessionExpiredListeners = new Set< + (event: OmsClientSessionExpiredEvent) => void +>(); function ensureFeeOptionSelectionListener() { feeOptionSelectionSubscription ??= @@ -186,6 +197,24 @@ function ensureFeeOptionSelectionListener() { ); } +function resetSessionExpiredReplay() { + latestSessionExpiredEvent = null; +} + +function handleNativeSessionExpired(event: OmsNativeClientSessionExpiredEvent) { + const sessionExpiredEvent = event as OmsClientSessionExpiredEvent; + latestSessionExpiredEvent = sessionExpiredEvent; + for (const listener of Array.from(sessionExpiredListeners)) { + listener(sessionExpiredEvent); + } +} + +function ensureSessionExpiredListener() { + sessionExpiredSubscription ??= OmsClientReactNativeSdk.onSessionExpired( + handleNativeSessionExpired + ); +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -238,6 +267,8 @@ async function withFeeOptionSelector( } export function configure(config: OmsClientConfig): Promise { + resetSessionExpiredReplay(); + ensureSessionExpiredListener(); return OmsClientReactNativeSdk.configure( config.publishableKey, config.environment?.walletApiUrl ?? null, @@ -258,9 +289,18 @@ export function getSession(): Promise { export function onSessionExpired( listener: (event: OmsClientSessionExpiredEvent) => void ): EventSubscription { - return OmsClientReactNativeSdk.onSessionExpired( - listener as (event: OmsNativeClientSessionExpiredEvent) => void - ); + ensureSessionExpiredListener(); + sessionExpiredListeners.add(listener); + + if (latestSessionExpiredEvent != null) { + listener(latestSessionExpiredEvent); + } + + return { + remove() { + sessionExpiredListeners.delete(listener); + }, + }; } export function getSupportedNetworks(): Promise { @@ -268,13 +308,14 @@ export function getSupportedNetworks(): Promise { } export function startEmailAuth(email: string): Promise { + resetSessionExpiredReplay(); return OmsClientReactNativeSdk.startEmailAuth(email); } export async function completeEmailAuth( params: CompleteEmailAuthParams ): Promise { - return hydrateCompleteAuthResult( + const result = hydrateCompleteAuthResult( await OmsClientReactNativeSdk.completeEmailAuth( params.code, params.walletSelection ?? null, @@ -282,11 +323,14 @@ export async function completeEmailAuth( stringifyOptionalNumber(params.sessionLifetimeSeconds) ) ); + resetSessionExpiredReplay(); + return result; } export async function signInWithOidcIdToken( params: SignInWithOidcIdTokenParams ): Promise { + resetSessionExpiredReplay(); return hydrateCompleteAuthResult( await OmsClientReactNativeSdk.signInWithOidcIdToken( params.idToken, @@ -302,6 +346,7 @@ export async function signInWithOidcIdToken( export function startOidcRedirectAuth( params: StartOidcRedirectAuthParams ): Promise { + resetSessionExpiredReplay(); return OmsClientReactNativeSdk.startOidcRedirectAuth( stringifyRequiredJson(params.provider, 'provider'), params.redirectUri, @@ -315,13 +360,20 @@ export function startOidcRedirectAuth( export async function handleOidcRedirectCallback( params: HandleOidcRedirectCallbackParams = {} ): Promise { - return hydrateOidcRedirectAuthResult( + const result = hydrateOidcRedirectAuthResult( await OmsClientReactNativeSdk.handleOidcRedirectCallback( params.callbackUrl ?? null, params.walletSelection ?? null, stringifyOptionalNumber(params.sessionLifetimeSeconds) ) ); + if ( + result.type !== 'notOidcRedirectCallback' && + result.type !== 'noPendingAuth' + ) { + resetSessionExpiredReplay(); + } + return result; } export function listWallets(): Promise { @@ -331,19 +383,25 @@ export function listWallets(): Promise { export function useWallet( walletId: string ): Promise { - return OmsClientReactNativeSdk.useWallet(walletId); + return OmsClientReactNativeSdk.useWallet(walletId).then((result) => { + resetSessionExpiredReplay(); + return result; + }); } -export function createWallet( +export async function createWallet( params: CreateWalletParams = {} ): Promise { - return OmsClientReactNativeSdk.createWallet( + const result = await OmsClientReactNativeSdk.createWallet( params.walletType ?? null, params.reference ?? null ); + resetSessionExpiredReplay(); + return result; } export function signOut(): Promise { + resetSessionExpiredReplay(); return OmsClientReactNativeSdk.signOut(); } From b26f93e6e9cd0d4d293a9de26dd2ce4ee5fe164a Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Wed, 10 Jun 2026 16:56:18 +0300 Subject: [PATCH 5/6] test: add client native bridge coverage --- .github/workflows/ci.yml | 3 + .github/workflows/quick-checks.yml | 3 + TESTING.md | 33 ++- package.json | 1 + test/client.native.test.js | 412 +++++++++++++++++++++++++++++ 5 files changed, 438 insertions(+), 14 deletions(-) create mode 100644 test/client.native.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e53ea12..e86353d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: - name: Typecheck files run: yarn typecheck + - name: Run unit tests + run: yarn test + - name: Typecheck Expo example run: npm --prefix examples/expo-example run typecheck diff --git a/.github/workflows/quick-checks.yml b/.github/workflows/quick-checks.yml index 57ad0ff..29d4e41 100644 --- a/.github/workflows/quick-checks.yml +++ b/.github/workflows/quick-checks.yml @@ -33,5 +33,8 @@ jobs: - name: Typecheck files run: yarn typecheck + - name: Run unit tests + run: yarn test + - name: Typecheck Expo example run: npm --prefix examples/expo-example run typecheck diff --git a/TESTING.md b/TESTING.md index ff2073f..805b2b7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -4,10 +4,11 @@ How testing works in this repo. `AGENTS.md` points here so agents know how to ve ## Current state -**No automated test suite exists yet.** The repo is in early alpha. Until a test -runner is set up, verification is manual (see checklist below). +The repo has a focused unit test suite for JavaScript bridge behavior. The suite uses Node's +built-in test runner against the generated CommonJS package output, so `yarn test` runs +`yarn prepare` first and then executes `test/*.test.js`. -When tests are added, this file should be updated with the runner, locations, and commands. +Native device/simulator verification is still manual unless called out by a specific change. --- @@ -22,6 +23,9 @@ yarn lint # TypeScript type-check (library) yarn typecheck +# Unit tests +yarn test + # TypeScript type-check (Expo example) npm --prefix examples/expo-example ci npm --prefix examples/expo-example run typecheck @@ -36,14 +40,14 @@ Android and iOS CI checks pass before merging; validate locally when you need fa --- -## Planned test setup - -When automated tests are introduced, the intended split is: +## Test setup -- **Unit tests** — pure TypeScript functions in `src/` (e.g. `formatUnits`, `parseUnits`, - `oidcProviders`). No native bridge, no device. Target runner: Jest or Vitest. - - Location: `src/__tests__/` or co-located `*.test.ts` files. - - Command (proposed): `yarn test` +- **Unit tests** — JavaScript bridge and pure TypeScript behavior in `src/`. + - Runner: Node's built-in `node:test`. + - Location: `test/*.test.js`. + - Command: `yarn test`. + - Current native bridge tests mock `lib/commonjs/NativeOmsClientReactNativeSdk.js` after + `yarn prepare`, then import `lib/commonjs/client.native.js`. - **Integration tests** — tests that exercise the native bridge on a real device or simulator. These require a connected device/emulator and valid OMS credentials. Not expected to run in @@ -51,9 +55,10 @@ When automated tests are introduced, the intended split is: --- -## Conventions (for when tests are added) +## Conventions -- Name unit test files `*.test.ts` (co-located next to source) or place them in `src/__tests__/`. +- Name unit test files `*.test.js` under `test/` unless the project adopts a TypeScript-aware + test runner later. - Every bug fix should include a regression test. - Every new exported function should have at least one happy-path unit test. - Keep unit tests free of native-bridge calls — mock `NativeOmsClientReactNativeSdk` at the module @@ -69,5 +74,5 @@ When automated tests are introduced, the intended split is: | Typecheck (library) | `yarn typecheck` | | Typecheck (Expo example) | `npm --prefix examples/expo-example run typecheck` | | Build library | `yarn prepare` | -| Run unit tests *(planned)* | `yarn test` | -| Full CI equivalent | `yarn lint && yarn typecheck && yarn prepare` | +| Run unit tests | `yarn test` | +| Full CI equivalent | `yarn lint && yarn typecheck && yarn test` | diff --git a/package.json b/package.json index f3f05cd..4efebd3 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "expo-example:ios": "cd examples/expo-example && npm run ios", "clean": "del-cli android/build examples/sdk-example/android/build examples/sdk-example/android/app/build examples/sdk-example/ios/build examples/trails-actions-example/android/build examples/trails-actions-example/android/app/build examples/trails-actions-example/ios/build lib", "prepare": "bob build", + "test": "yarn prepare && node --test test/*.test.js", "typecheck": "tsc", "lint": "eslint \"**/*.{js,ts,tsx}\"" }, diff --git a/test/client.native.test.js b/test/client.native.test.js new file mode 100644 index 0000000..bdd7053 --- /dev/null +++ b/test/client.native.test.js @@ -0,0 +1,412 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const test = require('node:test'); + +const rootDir = path.resolve(__dirname, '..'); +const clientModulePath = path.join(rootDir, 'lib/commonjs/client.native.js'); +const nativeModulePath = path.join( + rootDir, + 'lib/commonjs/NativeOmsClientReactNativeSdk.js' +); + +const config = { + publishableKey: 'test-publishable-key', + projectId: 'test-project', +}; + +function wallet(id = 'wallet-1') { + return { + id, + type: 'ethereum', + address: `0x${id.replace(/\D/g, '').padStart(40, '0')}`, + reference: null, + }; +} + +function credential(id = 'credential-1') { + return { + credentialId: id, + expiresAt: '2026-06-17T00:00:00.000Z', + isCaller: true, + }; +} + +function walletSelectedResult() { + const selectedWallet = wallet(); + return { + type: 'walletSelected', + walletAddress: selectedWallet.address, + wallet: selectedWallet, + wallets: [selectedWallet], + credential: credential(), + }; +} + +function pendingWalletSelectionResult(id = 'pending-1') { + const pendingWallet = wallet(); + return { + type: 'walletSelection', + walletAddress: null, + wallet: null, + wallets: [pendingWallet], + credential: credential(), + pendingSelection: { + id, + walletType: 'ethereum', + wallets: [pendingWallet], + credential: credential(), + }, + }; +} + +function walletActivationResult(id = 'wallet-2') { + const activatedWallet = wallet(id); + return { + walletAddress: activatedWallet.address, + wallet: activatedWallet, + }; +} + +function sessionExpiredEvent(id = 'expired') { + return { + session: { + walletAddress: `0x${id.replace(/\D/g, '').padStart(40, '1')}`, + expiresAt: '2026-06-10T00:00:00.000Z', + loginType: 'Email', + sessionEmail: `${id}@example.com`, + }, + expiredAt: '2026-06-10T00:00:00.000Z', + }; +} + +function makeRecorder(calls, name, implementation) { + calls[name] = []; + return (...args) => { + calls[name].push(args); + return implementation(...args); + }; +} + +function loadClient(overrides = {}) { + const clientModuleId = require.resolve(clientModulePath); + const nativeModuleId = require.resolve(nativeModulePath); + delete require.cache[clientModuleId]; + delete require.cache[nativeModuleId]; + + const calls = {}; + const native = {}; + native.onSessionExpired = makeRecorder( + calls, + 'onSessionExpired', + (listener) => { + native.sessionExpiredListener = listener; + return { + remove() { + native.sessionExpiredListener = null; + }, + }; + } + ); + native.configure = makeRecorder( + calls, + 'configure', + overrides.configure ?? (async () => undefined) + ); + native.startEmailAuth = makeRecorder( + calls, + 'startEmailAuth', + overrides.startEmailAuth ?? (async () => undefined) + ); + native.completeEmailAuth = makeRecorder( + calls, + 'completeEmailAuth', + overrides.completeEmailAuth ?? (async () => walletSelectedResult()) + ); + native.signInWithOidcIdToken = makeRecorder( + calls, + 'signInWithOidcIdToken', + overrides.signInWithOidcIdToken ?? (async () => walletSelectedResult()) + ); + native.startOidcRedirectAuth = makeRecorder( + calls, + 'startOidcRedirectAuth', + overrides.startOidcRedirectAuth ?? + (async () => ({ + authorizationUrl: 'https://auth.example.com', + state: 'state', + challenge: 'challenge', + })) + ); + native.handleOidcRedirectCallback = makeRecorder( + calls, + 'handleOidcRedirectCallback', + overrides.handleOidcRedirectCallback ?? + (async () => ({ type: 'completed', wallet: wallet() })) + ); + native.useWallet = makeRecorder( + calls, + 'useWallet', + overrides.useWallet ?? (async () => walletActivationResult()) + ); + native.createWallet = makeRecorder( + calls, + 'createWallet', + overrides.createWallet ?? (async () => walletActivationResult()) + ); + native.selectWalletForPendingSelection = makeRecorder( + calls, + 'selectWalletForPendingSelection', + overrides.selectWalletForPendingSelection ?? + (async () => walletActivationResult()) + ); + native.createAndSelectWalletForPendingSelection = makeRecorder( + calls, + 'createAndSelectWalletForPendingSelection', + overrides.createAndSelectWalletForPendingSelection ?? + (async () => walletActivationResult()) + ); + native.signOut = makeRecorder( + calls, + 'signOut', + overrides.signOut ?? (async () => undefined) + ); + + require.cache[nativeModuleId] = { + id: nativeModuleId, + filename: nativeModuleId, + loaded: true, + exports: { + __esModule: true, + default: native, + }, + }; + + return { + calls, + client: require(clientModuleId), + native, + }; +} + +async function configure(client) { + await client.configure(config); +} + +function emitSessionExpired(native, event) { + assert.equal(typeof native.sessionExpiredListener, 'function'); + native.sessionExpiredListener(event); +} + +function subscribe(client) { + const events = []; + const subscription = client.onSessionExpired((event) => { + events.push(event); + }); + return { events, subscription }; +} + +async function expectReplayCleared(action) { + const { client, native } = loadClient(); + const staleEvent = sessionExpiredEvent('stale'); + await configure(client); + emitSessionExpired(native, staleEvent); + + await action(client, native); + + const { events } = subscribe(client); + assert.deepEqual(events, []); +} + +test('replays native session expiry to late JS subscribers and fans out future events', async () => { + const firstEvent = sessionExpiredEvent('first'); + const secondEvent = sessionExpiredEvent('second'); + const thirdEvent = sessionExpiredEvent('third'); + + let native; + const loaded = loadClient({ + configure: async () => { + emitSessionExpired(native, firstEvent); + }, + }); + native = loaded.native; + + await configure(loaded.client); + assert.equal(loaded.calls.onSessionExpired.length, 1); + + const firstSubscriber = subscribe(loaded.client); + assert.deepEqual(firstSubscriber.events, [firstEvent]); + + const secondSubscriber = subscribe(loaded.client); + assert.deepEqual(secondSubscriber.events, [firstEvent]); + assert.equal(loaded.calls.onSessionExpired.length, 1); + + emitSessionExpired(native, secondEvent); + assert.deepEqual(firstSubscriber.events, [firstEvent, secondEvent]); + assert.deepEqual(secondSubscriber.events, [firstEvent, secondEvent]); + + firstSubscriber.subscription.remove(); + emitSessionExpired(native, thirdEvent); + + assert.deepEqual(firstSubscriber.events, [firstEvent, secondEvent]); + assert.deepEqual(secondSubscriber.events, [ + firstEvent, + secondEvent, + thirdEvent, + ]); +}); + +test('clears cached session expiry when auth or session state is reset', async () => { + await expectReplayCleared((client) => configure(client)); + await expectReplayCleared((client) => + client.startEmailAuth('user@example.com') + ); + await expectReplayCleared((client) => + client.startOidcRedirectAuth({ + provider: { id: 'google' }, + redirectUri: 'example://auth', + }) + ); + await expectReplayCleared((client) => + client.signInWithOidcIdToken({ + idToken: 'id-token', + issuer: 'https://issuer.example.com', + audience: 'audience', + }) + ); + await expectReplayCleared((client) => + client.completeEmailAuth({ code: '123456' }) + ); + await expectReplayCleared((client) => + client.handleOidcRedirectCallback({ + callbackUrl: 'example://auth?code=abc', + }) + ); + await expectReplayCleared((client) => client.useWallet('wallet-1')); + await expectReplayCleared((client) => client.createWallet()); + await expectReplayCleared((client) => client.signOut()); +}); + +test('clears cached session expiry when pending wallet selection activates a wallet', async () => { + for (const selectionAction of ['selectWallet', 'createAndSelectWallet']) { + const { client, native } = loadClient({ + completeEmailAuth: async () => pendingWalletSelectionResult(), + }); + await configure(client); + + const result = await client.completeEmailAuth({ + code: '123456', + walletSelection: 'manual', + }); + const staleEvent = sessionExpiredEvent(selectionAction); + emitSessionExpired(native, staleEvent); + + if (selectionAction === 'selectWallet') { + await result.pendingSelection.selectWallet('wallet-1'); + } else { + await result.pendingSelection.createAndSelectWallet('reference'); + } + + const { events } = subscribe(client); + assert.deepEqual(events, []); + } +}); + +test('does not clear cached session expiry for ignored OIDC redirect callbacks', async () => { + for (const type of ['notOidcRedirectCallback', 'noPendingAuth']) { + const { client, native } = loadClient({ + handleOidcRedirectCallback: async () => ({ type }), + }); + const staleEvent = sessionExpiredEvent(type); + await configure(client); + emitSessionExpired(native, staleEvent); + + assert.deepEqual(await client.handleOidcRedirectCallback(), { type }); + + const { events } = subscribe(client); + assert.deepEqual(events, [staleEvent]); + } +}); + +test('passes auth session lifetime and login hint parameters to native', async () => { + const { calls, client } = loadClient(); + + await client.completeEmailAuth({ + code: '123456', + walletSelection: 'manual', + walletType: 'ethereum', + sessionLifetimeSeconds: 3600, + }); + await client.completeEmailAuth({ code: '654321' }); + + assert.deepEqual(calls.completeEmailAuth[0], [ + '123456', + 'manual', + 'ethereum', + '3600', + ]); + assert.deepEqual(calls.completeEmailAuth[1], ['654321', null, null, null]); + + await client.signInWithOidcIdToken({ + idToken: 'id-token', + issuer: 'https://issuer.example.com', + audience: 'audience', + walletSelection: 'automatic', + walletType: 'ethereum', + sessionLifetimeSeconds: 7200, + }); + assert.deepEqual(calls.signInWithOidcIdToken[0], [ + 'id-token', + 'https://issuer.example.com', + 'audience', + 'automatic', + 'ethereum', + '7200', + ]); + + await client.handleOidcRedirectCallback({ + callbackUrl: 'example://auth?code=abc', + walletSelection: 'manual', + sessionLifetimeSeconds: 1800, + }); + await client.handleOidcRedirectCallback(); + assert.deepEqual(calls.handleOidcRedirectCallback[0], [ + 'example://auth?code=abc', + 'manual', + '1800', + ]); + assert.deepEqual(calls.handleOidcRedirectCallback[1], [null, null, null]); + + const provider = { + id: 'google', + relayRedirectUri: 'https://relay.example.com/callback', + }; + await client.startOidcRedirectAuth({ + provider, + redirectUri: 'example://auth', + walletType: 'ethereum', + authorizeParams: { prompt: 'select_account' }, + loginHint: 'user@example.com', + }); + await client.startOidcRedirectAuth({ + provider, + redirectUri: 'example://auth', + relayRedirectUri: null, + }); + + assert.deepEqual(calls.startOidcRedirectAuth[0], [ + JSON.stringify(provider), + 'example://auth', + 'ethereum', + 'https://relay.example.com/callback', + JSON.stringify({ prompt: 'select_account' }), + 'user@example.com', + ]); + assert.deepEqual(calls.startOidcRedirectAuth[1], [ + JSON.stringify(provider), + 'example://auth', + null, + null, + null, + null, + ]); +}); From 50a59b64c907d5eeb4d8cd6197ff02aee9a58913 Mon Sep 17 00:00:00 2001 From: Tolgahan Date: Wed, 10 Jun 2026 17:17:49 +0300 Subject: [PATCH 6/6] docs: add publishing guide --- AGENTS.md | 2 +- CONTRIBUTING.md | 4 +-- PUBLISHING.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 37 +++------------------ 4 files changed, 94 insertions(+), 36 deletions(-) create mode 100644 PUBLISHING.md diff --git a/AGENTS.md b/AGENTS.md index 684e467..2f3aefa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,7 @@ plan for when automated tests are added. first; leave full native builds to CI. - The `resolutions` block in `package.json` pins `0xtrails` / `@0xtrails/*` — update all three entries together when bumping the trails version. -- Pre-release versions use the `0.x.y-alpha.N` scheme. Publishing steps are in `README.md`. +- Pre-release versions use the `0.x.y-alpha.N` scheme. Publishing steps are in `PUBLISHING.md`. ## CI/CD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dccb1a5..3476a45 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,8 +53,8 @@ cd examples/expo-example && npm install && npm start ## Publishing (alpha) -Publishing steps are documented in `README.md` under the alpha publishing section. Only maintainers -with npm publish access should publish. +Publishing steps are documented in `PUBLISHING.md`. Only maintainers with npm publish access should +publish. ## Signed commits diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..fa1384f --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,87 @@ +# Publishing + +Release process for `@0xsequence/oms-react-native-sdk`. + +Only maintainers with npm publish access should publish. Publish from `master` after CI is green. + +## 1. Choose The Version + +Pre-release versions use `0.x.y-alpha.N`, for example `0.1.0-alpha.2`. + +Check that the version is not already published: + +```sh +npm view @0xsequence/oms-react-native-sdk@ version +``` + +An npm 404 means the version is available. If npm prints a version, choose a new version. + +## 2. Prepare The Release Commit + +Update: + +- `package.json` `version` +- `CHANGELOG.md` +- native SDK references if they changed: + - `android/build.gradle` + - `OmsClientReactNativeSdk.podspec` + - `README.md` + - `API.md` + +Install after editing package metadata: + +```sh +yarn install +``` + +Commit the release changes before publishing. + +## 3. Verify + +Run the standard checks from a clean worktree: + +```sh +git status --short +yarn lint +yarn typecheck +yarn test +yarn sdk-example build:android +yarn sdk-example build:ios +``` + +Do not publish if any command fails. If native SDK versions changed, confirm those versions are +already available from Maven Central and CocoaPods. + +## 4. Dry Run + +Build the package and inspect what npm would publish: + +```sh +yarn prepare +yarn npm publish --dry-run --access public --tag alpha +``` + +The dry run should include `lib`, `src`, `android`, `ios`, and +`OmsClientReactNativeSdk.podspec`. + +## 5. Publish + +Confirm the npm account, then publish: + +```sh +yarn npm whoami +yarn npm publish --access public --tag alpha +``` + +Use `--tag alpha` for alpha releases so prereleases do not become the default `latest` install. + +## 6. Confirm + +Verify npm sees the published version: + +```sh +npm view @0xsequence/oms-react-native-sdk@ version +``` + +If the package should become the default install later, move the npm dist-tag deliberately in a +separate step. diff --git a/README.md b/README.md index bf71c97..e6d0883 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,10 @@ on the underlying native SDKs. excluded from the root Yarn workspace so it is not linked to the local SDK source. +## Publishing + +See [PUBLISHING.md](./PUBLISHING.md) for the release process. + ## License MIT @@ -172,36 +176,3 @@ MIT --- Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob) - -## Publishing (for `alpha`) - -Publish from a clean worktree. The Android and iOS native SDK dependencies are -resolved from Maven Central and CocoaPods by Gradle and CocoaPods; the React -Native wrapper podspec is shipped in the npm package and consumed from -`node_modules` by React Native autolinking. - -Before publishing a new release, update `package.json` with the target npm -version and make sure that exact version has not already been published: - -```sh -npm view @0xsequence/oms-react-native-sdk@ version -``` - -An npm 404 means that version is available. If npm prints a version, choose a -new version before publishing. - -Then verify and publish: - -```sh -git status --short -yarn typecheck -yarn lint -yarn prepare -yarn sdk-example build:android -yarn sdk-example build:ios -yarn npm publish --dry-run --access public --tag alpha -yarn npm publish --access public --tag alpha -``` - -The dry-run should include `lib`, `src`, `android`, `ios`, and -`OmsClientReactNativeSdk.podspec`.