Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fc0a1a1
chore: scaffold cloud backup types and services
0xnullifier Mar 26, 2026
5942a72
feat: add Google Drive provider with OAuth (extension + iOS PKCE)
0xnullifier Mar 27, 2026
db7dbd5
feat: wire cloud backup through intercom and add test UI in settings
0xnullifier Mar 27, 2026
559ab01
feat: cloud backup restore and import flow
0xnullifier Mar 31, 2026
60d9074
feat: passkey based backup
0xnullifier Apr 5, 2026
236fc43
fix: importStore direct dump instead of `StoreSnapshot`
0xnullifier Apr 6, 2026
f266b3d
feat: autobackup
0xnullifier Apr 7, 2026
10efe1a
feat: native iOS passkey with PRF and cross-platform bridge
0xnullifier Apr 10, 2026
9cf6dcb
chore: update translation files
github-actions[bot] Apr 10, 2026
029bbcf
feat: simplify Google auth (remove userinfo fetch), add silent auth r…
0xnullifier Apr 11, 2026
f6064f7
fix: make initial account private and update wallet accounts on canon…
0xnullifier Apr 12, 2026
6a46f3b
feat: enable auto-backup by default after cloud restore
0xnullifier Apr 12, 2026
55ccdf1
refactor: replace markDirty with explicit triggerBackup
0xnullifier Apr 12, 2026
79aa6eb
feat: enable mobile sync and clean up mobile adapter types
0xnullifier Apr 12, 2026
c448466
fix: persist wallet settings by reading from vault storage
0xnullifier Apr 13, 2026
172c160
chore: add autobackup debug log and reformat imports
0xnullifier Apr 13, 2026
b3f28c6
debug: include client_secret in refresh and log raw refresh token
0xnullifier Apr 13, 2026
847b0cd
fix: canonicalize race, stale client, and missing auth keys after res…
0xnullifier Apr 13, 2026
f7c99dc
fix: auto-backup hooks register on all platforms; skip initial backup…
0xnullifier Apr 13, 2026
d9d342d
fix: skip auto-backup after canonicalization restore; canonicalize ne…
0xnullifier Apr 14, 2026
d47cc35
fix: do not add mismatch if onchain commitment is zero
0xnullifier Apr 15, 2026
d59c34f
refactor: remove sync canonicalization; add manual restore button
0xnullifier Apr 24, 2026
6611392
chore: add changelog entry for cloud backup
0xnullifier Apr 24, 2026
75e09bd
refactor: switch extension Google OAuth back to chrome.identity
0xnullifier Apr 24, 2026
6bd8ce7
feat: android google drive oauth
0xnullifier Apr 24, 2026
619c2ec
feat(android): native Google OAuth via Capacitor plugin + Authorizati…
0xnullifier Apr 24, 2026
4fbd3f5
feat(extension): switch Google OAuth to launchWebAuthFlow + PKCE
0xnullifier Apr 24, 2026
f9e029f
wip: extension oauth
0xnullifier Apr 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Features

* [FEATURE][all] Cloud backup & restore via Google Drive. Wallet state (accounts, settings, miden-client DB, transaction DB) is encrypted with either a user-supplied password (PBKDF2) or a WebAuthn / native iOS passkey (PRF-derived key) and uploaded to the user's Google Drive `appDataFolder`. Auto-backup fires after account creation, settings changes, transaction completion, and when new consumable notes arrive; the encryption key is persisted in vault storage so subsequent backups run silently. Onboarding and Forgot Password flows gain an **Import from Cloud** path that downloads + decrypts the backup and restores the wallet end-to-end; auto-backup is re-enabled automatically after a cloud restore (skipping the redundant initial upload). Cloud Backup settings exposes a **Restore from Backup** button that pulls fresh state from Drive on-demand using the vault's stored encryption key — no password / passkey prompt required, no onboarding detour. iOS uses native Keychain-backed passkeys bridged to the JS layer via Capacitor; extension uses `chrome.identity` OAuth with PKCE and a tab-based bridge for passkey registration.
* [FEATURE][all] Transaction-complete modal now surfaces a **View on Midenscan** action alongside **Done**. Desktop / extension opens the explorer in a new tab; mobile opens it as a native `InAppBrowser` overlay so dismissing the overlay returns the user to the completion screen with no state loss. URL resolved per-network via a new `MIDEN_EXPLORER_ENDPOINTS` map (testnet / devnet); localnet has no explorer → button hidden. The on-chain tx hash is plumbed through `SendManager.onSubmit` → `lastCompletedTxHash` in the Zustand store, cleared at the start of each send so the button never points at a stale hash. (#203)
* [UX][all] Transaction-complete modal no longer auto-closes 3 s after success — user now dismisses explicitly, giving time to read the confirmation and tap **View on Midenscan**. (#203)

Expand Down
1 change: 1 addition & 0 deletions android/.idea/deploymentTargetSelector.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
// Biometric authentication for hardware security
implementation 'androidx.biometric:biometric:1.1.0'
// Google Identity Services — AuthorizationClient for Drive scope OAuth (cloud backup)
implementation 'com.google.android.gms:play-services-auth:21.3.0'
}

apply from: 'capacitor.build.gradle'
Expand Down
1 change: 1 addition & 0 deletions android/app/capacitor.build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
Expand Down
147 changes: 147 additions & 0 deletions android/app/src/main/java/com/miden/wallet/GoogleAuthPlugin.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package com.miden.wallet

import android.app.Activity
import android.content.Intent
import android.util.Log
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import com.google.android.gms.auth.api.identity.AuthorizationRequest
import com.google.android.gms.auth.api.identity.AuthorizationResult
import com.google.android.gms.auth.api.identity.Identity
import com.google.android.gms.common.api.Scope

/**
* Native Google OAuth bridge for cloud backup on Android.
*
* Uses the Google Identity Services `AuthorizationClient` (Google Play Services)
* to obtain an OAuth access token for the requested scopes — native UI, no
* browser redirect or custom URI scheme. The OAuth client is picked up
* implicitly from the app's package name + signing SHA-1 (registered as an
* Android-type OAuth client in Google Cloud Console), so no client ID needs
* to be passed in from JS.
*
* Access tokens returned by this API live ~1 hour; Google manages silent
* refresh internally on subsequent `signInSilently` calls — we don't persist
* a refresh token on device (matches chrome.identity behavior on the
* extension path).
*/
@CapacitorPlugin(name = "GoogleAuthAndroid")
class GoogleAuthPlugin : Plugin() {

companion object {
private const val TAG = "GoogleAuthAndroid"
private const val REQUEST_AUTHORIZE = 9001
// Google access tokens live ~1h; use a slightly conservative expiry so the
// JS layer eagerly re-requests via signInSilently (which returns cached).
private const val TOKEN_LIFETIME_SECONDS = 55 * 60
}

private var pendingCall: PluginCall? = null

@PluginMethod
fun signIn(call: PluginCall) {
authorize(call, interactive = true)
}

@PluginMethod
fun signInSilently(call: PluginCall) {
authorize(call, interactive = false)
}

private fun authorize(call: PluginCall, interactive: Boolean) {
val scopesArray: JSArray = call.getArray("scopes") ?: run {
call.reject("scopes array is required")
return
}

val scopes = try {
(0 until scopesArray.length()).map { Scope(scopesArray.getString(it)) }
} catch (e: Exception) {
call.reject("scopes must be an array of strings", e)
return
}

val request = AuthorizationRequest.Builder()
.setRequestedScopes(scopes)
.build()

Identity.getAuthorizationClient(activity)
.authorize(request)
.addOnSuccessListener { result ->
if (result.hasResolution()) {
// User consent required.
if (!interactive) {
// Silent mode — signal that interactive auth is needed
// without triggering any UI.
val ret = JSObject()
ret.put("needsConsent", true)
call.resolve(ret)
return@addOnSuccessListener
}
val pendingIntent = result.pendingIntent ?: run {
call.reject("Authorization requires consent but no pending intent was returned")
return@addOnSuccessListener
}
try {
pendingCall = call
activity.startIntentSenderForResult(
pendingIntent.intentSender,
REQUEST_AUTHORIZE,
null,
0,
0,
0,
null
)
} catch (e: Exception) {
pendingCall = null
call.reject("Failed to launch authorization consent: ${e.message}", e)
}
} else {
resolveWithResult(call, result)
}
}
.addOnFailureListener { e ->
Log.w(TAG, "authorize failed", e)
call.reject("Authorization failed: ${e.message}", e)
}
}

override fun handleOnActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.handleOnActivityResult(requestCode, resultCode, data)
if (requestCode != REQUEST_AUTHORIZE) return

val call = pendingCall ?: return
pendingCall = null

if (resultCode != Activity.RESULT_OK) {
call.reject("User cancelled Google authorization")
return
}

try {
val result = Identity.getAuthorizationClient(activity).getAuthorizationResultFromIntent(data)
resolveWithResult(call, result)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse authorization result", e)
call.reject("Failed to parse authorization result: ${e.message}", e)
}
}

private fun resolveWithResult(call: PluginCall, result: AuthorizationResult) {
val accessToken = result.accessToken
if (accessToken == null) {
call.reject("Authorization succeeded but no access token was returned")
return
}
val ret = JSObject()
ret.put("accessToken", accessToken)
ret.put("grantedScopes", JSArray(result.grantedScopes))
ret.put("expiresIn", TOKEN_LIFETIME_SECONDS)
call.resolve(ret)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class MainActivity extends BridgeActivity {
protected void onCreate(Bundle savedInstanceState) {
// Register custom plugins before super.onCreate
registerPlugin(HardwareSecurityPlugin.class);
registerPlugin(GoogleAuthPlugin.class);

super.onCreate(savedInstanceState);
setupStatusBar();
Expand Down
3 changes: 3 additions & 0 deletions android/capacitor.settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')

include ':capacitor-browser'
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')

include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')

Expand Down
4 changes: 4 additions & 0 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
83CCE39C02FD0BF8DD39551F /* AppViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A6B2865F76F213517CFCCD1 /* AppViewController.swift */; };
B54E83DC5BCCDB512256423A /* LocalBiometricPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21A34DAD709C71D553F88951 /* LocalBiometricPlugin.swift */; };
C7D4E92A3F8B1C5D00A2B9E1 /* BarcodeScannerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D4E92B3F8B1C5D00A2B9E2 /* BarcodeScannerPlugin.swift */; };
D1A2B3C4E5F607890A1B2C3D /* PasskeyPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A2B3C4E5F607890A1B2C3E /* PasskeyPlugin.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand All @@ -35,6 +36,7 @@
873F0344C8952CB5585102E0 /* App.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
C7D4E92B3F8B1C5D00A2B9E2 /* BarcodeScannerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BarcodeScannerPlugin.swift; sourceTree = "<group>"; };
D1A2B3C4E5F607890A1B2C3E /* PasskeyPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PasskeyPlugin.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -80,6 +82,7 @@
50B271D01FEDC1A000F3C39B /* public */,
21A34DAD709C71D553F88951 /* LocalBiometricPlugin.swift */,
C7D4E92B3F8B1C5D00A2B9E2 /* BarcodeScannerPlugin.swift */,
D1A2B3C4E5F607890A1B2C3E /* PasskeyPlugin.swift */,
1A6B2865F76F213517CFCCD1 /* AppViewController.swift */,
873F0344C8952CB5585102E0 /* App.entitlements */,
);
Expand Down Expand Up @@ -179,6 +182,7 @@
B54E83DC5BCCDB512256423A /* LocalBiometricPlugin.swift in Sources */,
C7D4E92A3F8B1C5D00A2B9E1 /* BarcodeScannerPlugin.swift in Sources */,
83CCE39C02FD0BF8DD39551F /* AppViewController.swift in Sources */,
D1A2B3C4E5F607890A1B2C3D /* PasskeyPlugin.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
4 changes: 4 additions & 0 deletions ios/App/App/App.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@
<array>
<string>$(AppIdentifierPrefix)com.miden.wallet</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:api.midenbrowserwallet.com</string>
</array>
</dict>
</plist>
1 change: 1 addition & 0 deletions ios/App/App/AppViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ class AppViewController: CAPBridgeViewController {
override open func capacitorDidLoad() {
bridge?.registerPluginInstance(LocalBiometricPlugin())
bridge?.registerPluginInstance(BarcodeScannerPlugin())
bridge?.registerPluginInstance(PasskeyPlugin())
}
}
9 changes: 9 additions & 0 deletions ios/App/App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.849882985138-gbl44m5nmvuim6eiv4vmtg5rvoq4knqi</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
Expand Down
Loading
Loading