From 2f9fc114be2249957062f696af725d05732cf150 Mon Sep 17 00:00:00 2001 From: criptopoeta Date: Sat, 3 Oct 2026 16:09:20 -0300 Subject: [PATCH 1/3] Add Expo modular wallets native integration starter --- README.md | 4 + .../.env.example | 3 + .../.gitignore | 10 + .../CONTENTS.txt | 19 ++ modular-wallets-mobile-expo-starter/LICENSE | 201 ++++++++++++++++ modular-wallets-mobile-expo-starter/README.md | 65 +++++ .../app.config.example.ts | 8 + modular-wallets-mobile-expo-starter/check.py | 13 + .../.well-known/apple-app-site-association | 7 + .../domain/.well-known/assetlinks.json | 14 ++ .../android/build.gradle | 35 +++ .../android/src/main/AndroidManifest.xml | 1 + .../CircleModularWalletsNativeModule.kt | 110 +++++++++ .../expo-module.config.json | 9 + .../modules/circle-modular-wallets/index.ts | 56 +++++ .../ios/CircleModularWalletsNative.podspec | 31 +++ .../CircleModularWalletsNativeModule.swift | 106 ++++++++ .../circle-modular-wallets/package.json | 11 + .../package.json | 19 ++ .../src/native-passkey.ts | 227 ++++++++++++++++++ .../src/wallet.ts | 14 ++ 21 files changed, 963 insertions(+) create mode 100644 modular-wallets-mobile-expo-starter/.env.example create mode 100644 modular-wallets-mobile-expo-starter/.gitignore create mode 100644 modular-wallets-mobile-expo-starter/CONTENTS.txt create mode 100644 modular-wallets-mobile-expo-starter/LICENSE create mode 100644 modular-wallets-mobile-expo-starter/README.md create mode 100644 modular-wallets-mobile-expo-starter/app.config.example.ts create mode 100644 modular-wallets-mobile-expo-starter/check.py create mode 100644 modular-wallets-mobile-expo-starter/domain/.well-known/apple-app-site-association create mode 100644 modular-wallets-mobile-expo-starter/domain/.well-known/assetlinks.json create mode 100644 modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/android/build.gradle create mode 100644 modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/android/src/main/AndroidManifest.xml create mode 100644 modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/android/src/main/java/com/example/circlemodularwallets/CircleModularWalletsNativeModule.kt create mode 100644 modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/expo-module.config.json create mode 100644 modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/index.ts create mode 100644 modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/ios/CircleModularWalletsNative.podspec create mode 100644 modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/ios/CircleModularWalletsNativeModule.swift create mode 100644 modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/package.json create mode 100644 modular-wallets-mobile-expo-starter/package.json create mode 100644 modular-wallets-mobile-expo-starter/src/native-passkey.ts create mode 100644 modular-wallets-mobile-expo-starter/src/wallet.ts diff --git a/README.md b/README.md index 31255ec..411b80c 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ See [`user-controlled-wallets-social/README.md`](./user-controlled-wallets-socia Run `npm run server` and `npm run dev` in separate terminals. +### [`modular-wallets-mobile-expo-starter`](./modular-wallets-mobile-expo-starter) + +Source integration kit for native passkey registration, reconnect, and signing on Expo iOS and Android, plus a single-owner modular wallet adapter. Install into an existing Expo development build; see its README for native setup and outstanding physical-device acceptance checks. + ## License Apache 2.0 — see [LICENSE](./LICENSE). diff --git a/modular-wallets-mobile-expo-starter/.env.example b/modular-wallets-mobile-expo-starter/.env.example new file mode 100644 index 0000000..d60909b --- /dev/null +++ b/modular-wallets-mobile-expo-starter/.env.example @@ -0,0 +1,3 @@ +EXPO_PUBLIC_CIRCLE_CLIENT_KEY=YOUR_RESTRICTED_TESTNET_CLIENT_KEY +EXPO_PUBLIC_CIRCLE_CLIENT_URL=https://modular-sdk.circle.com/v1/rpc/w3s/buidl +EXPO_PUBLIC_WEBAUTHN_RP_ID=wallet.example.com diff --git a/modular-wallets-mobile-expo-starter/.gitignore b/modular-wallets-mobile-expo-starter/.gitignore new file mode 100644 index 0000000..98099d6 --- /dev/null +++ b/modular-wallets-mobile-expo-starter/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.env* +!.env.example +.expo/ +build/ +.gradle/ +Pods/ +*.keystore +*.mobileprovision +.DS_Store diff --git a/modular-wallets-mobile-expo-starter/CONTENTS.txt b/modular-wallets-mobile-expo-starter/CONTENTS.txt new file mode 100644 index 0000000..860699a --- /dev/null +++ b/modular-wallets-mobile-expo-starter/CONTENTS.txt @@ -0,0 +1,19 @@ +.env.example +.gitignore +LICENSE +README.md +app.config.example.ts +check.py +domain/.well-known/apple-app-site-association +domain/.well-known/assetlinks.json +modules/circle-modular-wallets/android/build.gradle +modules/circle-modular-wallets/android/src/main/AndroidManifest.xml +modules/circle-modular-wallets/android/src/main/java/com/example/circlemodularwallets/CircleModularWalletsNativeModule.kt +modules/circle-modular-wallets/expo-module.config.json +modules/circle-modular-wallets/index.ts +modules/circle-modular-wallets/ios/CircleModularWalletsNative.podspec +modules/circle-modular-wallets/ios/CircleModularWalletsNativeModule.swift +modules/circle-modular-wallets/package.json +package.json +src/native-passkey.ts +src/wallet.ts diff --git a/modular-wallets-mobile-expo-starter/LICENSE b/modular-wallets-mobile-expo-starter/LICENSE new file mode 100644 index 0000000..f49a4e1 --- /dev/null +++ b/modular-wallets-mobile-expo-starter/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/modular-wallets-mobile-expo-starter/README.md b/modular-wallets-mobile-expo-starter/README.md new file mode 100644 index 0000000..1d887bb --- /dev/null +++ b/modular-wallets-mobile-expo-starter/README.md @@ -0,0 +1,65 @@ +# Expo modular wallets starter — Android + iOS + +A small extract of our Expo implementation: official Circle native passkey SDK bridge, reconnect/signing adapter, and public single-owner smart-account builder. No treasury, multisig, agent policy, backend, deployed contracts, or credentials are included. This is a source integration kit, not an independently device-certified application. + +## Install into an existing Expo app + +1. Copy `modules/circle-modular-wallets` and `src` into the app root. Expo discovers local modules in `./modules`; if you override `expo.autolinking.nativeModulesDir`, point it there. Do not register this module as a config plugin. +2. Install packages: + ```sh + npx expo install expo-modules-core expo-dev-client expo-build-properties + npm install @circle-fin/modular-wallets-core@1.0.13 viem@2.38.3 webauthn-p256 + ``` +3. Merge `app.config.example.ts` into your app config; use your own bundle ID/package/domain. Copy `.env.example` to `.env.local` and replace placeholders with your own Circle testnet client configuration. Public client keys ship in the app: restrict them in Circle Console. Never put server API keys or build tokens in `EXPO_PUBLIC_*`. +4. Configure the same passkey domain, iOS bundle identifier, Android package, and signing fingerprint in Circle Console. Publish the two domain association templates over HTTPS at the exact `.well-known` paths, without authentication. Replace every placeholder. Include both development and release signing fingerprints when relevant. +5. Android SDK downloads require `MWSDK_MAVEN_USERNAME` and `MWSDK_MAVEN_PASSWORD` in the build worker, or `mwsdk.maven.username` / `mwsdk.maven.password` in your private `~/.gradle/gradle.properties`. Use your own GitHub package-read credential. Never commit it or add it to app config. +6. Build a native development client: + ```sh + npx expo run:ios + npx expo run:android + npx expo start --dev-client + ``` + Expo Go does not contain this module. iOS requires normal signing and associated-domain entitlements; use a physical passkey-capable device for acceptance checks. Native versions retained from our implementation: iOS 1.5.0, Android 1.5.3. Rebuild after native changes. + +## Register, reconnect, and build an account + +```ts +import { registerOfficialCircleNativeCredential, reconnectOfficialCircleNativeCredential } from './src/native-passkey'; +import { createWallet } from './src/wallet'; + +const config = { + clientKey: process.env.EXPO_PUBLIC_CIRCLE_CLIENT_KEY!, + clientUrl: process.env.EXPO_PUBLIC_CIRCLE_CLIENT_URL!, + expectedRpId: process.env.EXPO_PUBLIC_WEBAUTHN_RP_ID!, +}; +// Validate these environment values exist before displaying wallet actions. +const credential = await registerOfficialCircleNativeCredential({ ...config, userName: 'Your user label' }); +// Persist credential metadata against the authenticated user using your app's storage. +// Never persist a PIN or private key. On cold restart, reconnect BEFORE signing: +const reconnected = await reconnectOfficialCircleNativeCredential({ ...config, expectedCredentialId: credential.id }); +const account = await createWallet(reconnected, publicClient); +console.log(account.address); // Address calculation, not proof of deployment. +``` + +`publicClient` must use your chosen Circle-supported chain and authenticated RPC transport. Follow Circle's public smart-account sample for transport, bundler, paymaster, and transaction submission. Do not use browser passkey registration helpers on React Native; this kit supplies the native owner instead. The web SDK transport may access `window.location`; test it in Hermes or supply a documented native-compatible HTTP transport. No browser global shim is included. + +Submit reviewed calls with viem's `bundlerClient.sendUserOperation({ account, calls, paymaster: true })` only when your Circle Gas Station policy allows sponsorship. Wait for `waitForUserOperationReceipt({ hash })`, check receipt success, then read bytecode before marking deployment ready. Keep signing in a user-confirmed action; prevent concurrent register/login/sign operations in your UI. Start on testnet. No automatic mainnet transaction or demo funds movement is included. + +## Acceptance checks before shipping + +- Registration succeeds on physical iOS and Android; canceling the prompt leaves no false success. +- Cold-restart reconnect chooses the same credential and produces the same wallet address. +- Wrong RP domain / wrong credential fail closed before account use. +- A testnet transaction returns a successful receipt and deployed bytecode. Test refusal, insufficient funds, sponsorship denial, and network interruption. +- Release signing domain associations work as well as the dev build. + +The archive has been checked for excluded private integration and secrets; native compilation and physical-device transaction QA have not been performed for this standalone kit. + +## Public references + +- Circle public smart-account sample: https://github.com/circlefin/modularwallets-web-sdk/tree/master/examples/circle-smart-account +- Circle iOS SDK: https://github.com/circlefin/modularwallets-ios-sdk +- Circle Android SDK: https://github.com/circlefin/modularwallets-android-sdk +- Expo local native modules: https://docs.expo.dev/modules/get-started/ + +Third-party dependencies retain their licenses. This starter is licensed under Apache 2.0; see LICENSE. Third-party SDK licensing is independent. diff --git a/modular-wallets-mobile-expo-starter/app.config.example.ts b/modular-wallets-mobile-expo-starter/app.config.example.ts new file mode 100644 index 0000000..151b90f --- /dev/null +++ b/modular-wallets-mobile-expo-starter/app.config.example.ts @@ -0,0 +1,8 @@ +export default { + expo: { + name: 'Wallet Demo', slug: 'wallet-demo', + ios: { bundleIdentifier: 'com.example.walletdemo', associatedDomains: ['webcredentials:wallet.example.com'] }, + android: { package: 'com.example.walletdemo' }, + plugins: [['expo-build-properties', { ios: { deploymentTarget: '16.0' }, android: { minSdkVersion: 26 } }]], + }, +}; diff --git a/modular-wallets-mobile-expo-starter/check.py b/modular-wallets-mobile-expo-starter/check.py new file mode 100644 index 0000000..ab7d9b3 --- /dev/null +++ b/modular-wallets-mobile-expo-starter/check.py @@ -0,0 +1,13 @@ +from pathlib import Path +import json +root = Path(__file__).parent +for file in root.rglob('*'): + if not file.is_file() or '.git' in file.parts: continue + assert file.name != '.env', file + text = file.read_text() + for marker in ['@'+'bu/', 'plan'+'Ceremony', 'execute'+'CeremonyPlan', 'alch'+'__']: + assert marker not in text, file +config = json.loads((root/'modules/circle-modular-wallets/expo-module.config.json').read_text()) +assert config['platforms'] == ['apple', 'android'] +assert config['android']['modules'][0].startswith('com.example.') +print('Source boundary and module configuration checks passed') diff --git a/modular-wallets-mobile-expo-starter/domain/.well-known/apple-app-site-association b/modular-wallets-mobile-expo-starter/domain/.well-known/apple-app-site-association new file mode 100644 index 0000000..460a4cc --- /dev/null +++ b/modular-wallets-mobile-expo-starter/domain/.well-known/apple-app-site-association @@ -0,0 +1,7 @@ +{ + "webcredentials": { + "apps": [ + "YOUR_APPLE_TEAM_ID.com.example.walletdemo" + ] + } +} \ No newline at end of file diff --git a/modular-wallets-mobile-expo-starter/domain/.well-known/assetlinks.json b/modular-wallets-mobile-expo-starter/domain/.well-known/assetlinks.json new file mode 100644 index 0000000..be8c8e8 --- /dev/null +++ b/modular-wallets-mobile-expo-starter/domain/.well-known/assetlinks.json @@ -0,0 +1,14 @@ +[ + { + "relation": [ + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "com.example.walletdemo", + "sha256_cert_fingerprints": [ + "YOUR_SIGNING_CERTIFICATE_SHA256" + ] + } + } +] \ No newline at end of file diff --git a/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/android/build.gradle b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/android/build.gradle new file mode 100644 index 0000000..964f521 --- /dev/null +++ b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/android/build.gradle @@ -0,0 +1,35 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'expo-module-gradle-plugin' + +group = 'com.example.circlemodularwallets' +version = '1.0.0' + +def circleMavenUsername = System.getenv('MWSDK_MAVEN_USERNAME') ?: project.findProperty('mwsdk.maven.username') +def circleMavenPassword = System.getenv('MWSDK_MAVEN_PASSWORD') ?: project.findProperty('mwsdk.maven.password') + +repositories { + maven { + url 'https://maven.pkg.github.com/circlefin/modularwallets-android-sdk' + credentials { + username circleMavenUsername ?: '' + password circleMavenPassword ?: '' + } + authentication { + basic(BasicAuthentication) + } + } +} + +android { + namespace 'com.example.circlemodularwallets' + + defaultConfig { + minSdkVersion 26 + versionName version + } +} + +dependencies { + implementation 'circle.modularwallets:core:1.5.3' +} diff --git a/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/android/src/main/AndroidManifest.xml b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/android/src/main/java/com/example/circlemodularwallets/CircleModularWalletsNativeModule.kt b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/android/src/main/java/com/example/circlemodularwallets/CircleModularWalletsNativeModule.kt new file mode 100644 index 0000000..60f2958 --- /dev/null +++ b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/android/src/main/java/com/example/circlemodularwallets/CircleModularWalletsNativeModule.kt @@ -0,0 +1,110 @@ +package com.example.circlemodularwallets + +import com.circle.modularwallets.core.accounts.WebAuthnAccount +import com.circle.modularwallets.core.accounts.WebAuthnCredential +import com.circle.modularwallets.core.accounts.toWebAuthnAccount +import com.circle.modularwallets.core.accounts.toWebAuthnCredential +import com.circle.modularwallets.core.models.RegistrationCredential +import com.circle.modularwallets.core.models.WebAuthnMode +import com.circle.modularwallets.core.transports.http.toPasskeyTransport +import expo.modules.kotlin.exception.CodedException +import expo.modules.kotlin.functions.Coroutine +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class CircleModularWalletsNativeModule : Module() { + private var account: WebAuthnAccount? = null + + override fun definition() = ModuleDefinition { + Name("CircleModularWalletsNative") + + AsyncFunction("register") Coroutine { clientKey: String, clientUrl: String, userName: String -> + val activity = appContext.throwingActivity + val transport = toPasskeyTransport(activity, clientKey, clientUrl) + val credential = toWebAuthnCredential( + activity, + transport, + userName, + WebAuthnMode.Register + ) + account = toWebAuthnAccount(credential) + serializeCredential(credential, includeRegistration = true) + } + + AsyncFunction("login") Coroutine { clientKey: String, clientUrl: String -> + val activity = appContext.throwingActivity + val transport = toPasskeyTransport(activity, clientKey, clientUrl) + val credential = toWebAuthnCredential( + activity, + transport, + mode = WebAuthnMode.Login + ) + account = toWebAuthnAccount(credential) + serializeCredential(credential, includeRegistration = false) + } + + AsyncFunction("sign") Coroutine { messageHash: String -> + val activity = appContext.throwingActivity + val hydratedAccount = account ?: throw CircleNativeCredentialNotHydratedException() + val result = hydratedAccount.sign(activity, messageHash) + val raw = result.raw + mapOf( + "signature" to result.signature, + "webauthn" to mapOf( + "authenticatorData" to result.webAuthn.authenticatorData, + "clientDataJSON" to result.webAuthn.clientDataJSON, + "challengeIndex" to result.webAuthn.challengeIndex, + "typeIndex" to result.webAuthn.typeIndex, + "userVerificationRequired" to result.webAuthn.userVerificationRequired + ), + "raw" to mapOf( + "id" to raw.id, + "rawId" to raw.rawId, + "type" to raw.type, + "authenticatorAttachment" to raw.authenticatorAttachment, + "response" to mapOf( + "clientDataJSON" to raw.response.clientDataJSON, + "authenticatorData" to raw.response.authenticatorData, + "signature" to raw.response.signature, + "userHandle" to raw.response.userHandle + ) + ) + ) + } + } + + private fun serializeCredential( + credential: WebAuthnCredential, + includeRegistration: Boolean + ): Map { + val result = mutableMapOf( + "id" to credential.id, + "publicKey" to credential.publicKey, + "rpId" to credential.rpId + ) + + if (includeRegistration) { + val registration = credential.raw as? RegistrationCredential + if (registration != null) { + result["registration"] = mapOf( + "id" to registration.id, + "rawId" to registration.rawId, + "type" to registration.type, + "authenticatorAttachment" to registration.authenticatorAttachment, + "response" to mapOf( + "clientDataJSON" to registration.response.clientDataJSON, + "attestationObject" to registration.response.attestationObject, + "publicKey" to registration.response.publicKey, + "transports" to registration.response.transports + ) + ) + } + } + + return result + } +} + +private class CircleNativeCredentialNotHydratedException : CodedException( + "Circle native passkey account is not hydrated. Register or reconnect the credential before signing." +) diff --git a/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/expo-module.config.json b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/expo-module.config.json new file mode 100644 index 0000000..c399eed --- /dev/null +++ b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["apple", "android"], + "apple": { + "modules": ["CircleModularWalletsNativeModule"] + }, + "android": { + "modules": ["com.example.circlemodularwallets.CircleModularWalletsNativeModule"] + } +} diff --git a/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/index.ts b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/index.ts new file mode 100644 index 0000000..14719c3 --- /dev/null +++ b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/index.ts @@ -0,0 +1,56 @@ +import { requireOptionalNativeModule } from 'expo-modules-core'; + +export type CircleNativeRegistrationCredential = { + id: string; + rawId: string; + type: string; + authenticatorAttachment?: string | null; + response: { + clientDataJSON: string; + attestationObject: string; + publicKey?: string; + transports?: string[]; + }; +}; + +export type CircleNativeCredential = { + id: string; + publicKey: string; + rpId: string; + registration?: CircleNativeRegistrationCredential; +}; + +export type CircleNativeSignResult = { + signature: string; + webauthn: { + authenticatorData: string; + clientDataJSON: string; + challengeIndex: number; + typeIndex: number; + userVerificationRequired: boolean; + }; + raw: { + id: string; + rawId: string; + type: string; + authenticatorAttachment?: string | null; + response: { + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle?: string | null; + }; + }; +}; + +export type CircleModularWalletsNativeModule = { + register(clientKey: string, clientUrl: string, userName: string): Promise; + login(clientKey: string, clientUrl: string): Promise; + sign(messageHash: string): Promise; +}; + +export function resolveCircleModularWalletsNativeModule(): CircleModularWalletsNativeModule | null { + return requireOptionalNativeModule( + 'CircleModularWalletsNative' + ); +} diff --git a/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/ios/CircleModularWalletsNative.podspec b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/ios/CircleModularWalletsNative.podspec new file mode 100644 index 0000000..7ddcd70 --- /dev/null +++ b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/ios/CircleModularWalletsNative.podspec @@ -0,0 +1,31 @@ +require File.join( + File.dirname(`node --print "require.resolve('react-native/package.json')"`), + 'scripts/react_native_pods' +) + +Pod::Spec.new do |s| + s.name = 'CircleModularWalletsNative' + s.version = '1.0.0' + s.summary = 'Expo bridge for the official Circle Modular Wallets mobile SDK' + s.description = 'Native passkey registration, reconnect, and signing through the official Circle SDK.' + s.license = { :type => 'Apache-2.0' } + s.author = 'Wallet Starter' + s.homepage = 'https://github.com/circlefin/modularwallets-ios-sdk' + s.platforms = { :ios => '16.0' } + s.swift_version = '5.9' + s.source = { :path => '.' } + s.source_files = '**/*.swift' + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + spm_dependency( + s, + url: 'https://github.com/circlefin/modularwallets-ios-sdk.git', + requirement: { + kind: 'exactVersion', + version: '1.5.0' + }, + products: ['CircleModularWalletsCore'] + ) +end diff --git a/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/ios/CircleModularWalletsNativeModule.swift b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/ios/CircleModularWalletsNativeModule.swift new file mode 100644 index 0000000..cfc6bd2 --- /dev/null +++ b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/ios/CircleModularWalletsNativeModule.swift @@ -0,0 +1,106 @@ +import CircleModularWalletsCore +import ExpoModulesCore + +public final class CircleModularWalletsNativeModule: Module { + private var account: WebAuthnAccount? + + public func definition() -> ModuleDefinition { + Name("CircleModularWalletsNative") + + AsyncFunction("register") { + (clientKey: String, clientUrl: String, userName: String) async throws -> [String: Any] in + let transport = toPasskeyTransport(clientKey: clientKey, url: clientUrl) + let credential = try await toWebAuthnCredential( + transport: transport, + userName: userName, + mode: .register + ) + self.account = toWebAuthnAccount(credential) + return self.serializeCredential(credential, includeRegistration: true) + } + + AsyncFunction("login") { + (clientKey: String, clientUrl: String) async throws -> [String: Any] in + let transport = toPasskeyTransport(clientKey: clientKey, url: clientUrl) + let credential = try await toWebAuthnCredential( + transport: transport, + mode: .login + ) + self.account = toWebAuthnAccount(credential) + return self.serializeCredential(credential, includeRegistration: false) + } + + AsyncFunction("sign") { (messageHash: String) async throws -> [String: Any] in + guard let account = self.account else { + throw CircleNativeCredentialNotHydratedException() + } + + let result = try await account.sign(messageHash: messageHash) + return self.serializeSignResult(result) + } + } + + private func serializeCredential( + _ credential: WebAuthnCredential, + includeRegistration: Bool + ) -> [String: Any] { + var result: [String: Any] = [ + "id": credential.id, + "publicKey": credential.publicKey, + "rpId": credential.rpId + ] + + if includeRegistration, + let registration = credential.raw as? RegistrationCredential, + let response = registration.response as? AuthenticatorAttestationResponse { + result["registration"] = [ + "id": registration.id, + "rawId": registration.rawID.asString(), + "type": registration.type.rawValue, + "authenticatorAttachment": registration.authenticatorAttachment?.rawValue as Any, + "response": [ + "clientDataJSON": response.clientDataJSON.asString(), + "attestationObject": response.attestationObject.asString(), + "publicKey": response.publicKey?.asString() as Any, + "transports": response.transports ?? [] + ] + ] as [String: Any] + } + + return result + } + + private func serializeSignResult(_ result: SignResult) -> [String: Any] { + let raw = result.raw + let response = raw.response as? AuthenticatorAssertionResponse + + return [ + "signature": result.signature, + "webauthn": [ + "authenticatorData": result.webAuthn.authenticatorData, + "clientDataJSON": result.webAuthn.clientDataJSON, + "challengeIndex": result.webAuthn.challengeIndex, + "typeIndex": result.webAuthn.typeIndex, + "userVerificationRequired": result.webAuthn.userVerificationRequired + ], + "raw": [ + "id": raw.id, + "rawId": raw.rawID.asString(), + "type": raw.type.rawValue, + "authenticatorAttachment": raw.authenticatorAttachment?.rawValue as Any, + "response": [ + "clientDataJSON": response?.clientDataJSON.asString() ?? "", + "authenticatorData": response?.authenticatorData.asString() ?? "", + "signature": response?.signature.asString() ?? "", + "userHandle": response?.userHandle?.asString() as Any + ] + ] + ] + } +} + +private final class CircleNativeCredentialNotHydratedException: Exception { + override var reason: String { + "Circle native passkey account is not hydrated. Register or reconnect the credential before signing." + } +} diff --git a/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/package.json b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/package.json new file mode 100644 index 0000000..7c48013 --- /dev/null +++ b/modular-wallets-mobile-expo-starter/modules/circle-modular-wallets/package.json @@ -0,0 +1,11 @@ +{ + "name": "circle-modular-wallets", + "version": "0.1.0", + "private": true, + "main": "index.ts", + "peerDependencies": { + "expo": "*", + "expo-modules-core": "*", + "react-native": "*" + } +} diff --git a/modular-wallets-mobile-expo-starter/package.json b/modular-wallets-mobile-expo-starter/package.json new file mode 100644 index 0000000..80d0be6 --- /dev/null +++ b/modular-wallets-mobile-expo-starter/package.json @@ -0,0 +1,19 @@ +{ + "name": "modular-wallets-mobile-expo-starter", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "scripts": { + "check": "python3 check.py" + }, + "dependencies": { + "@circle-fin/modular-wallets-core": "1.0.13", + "viem": "2.38.3", + "webauthn-p256": "^0.0.10" + }, + "peerDependencies": { + "expo": "*", + "expo-modules-core": "*", + "react-native": "*" + } +} diff --git a/modular-wallets-mobile-expo-starter/src/native-passkey.ts b/modular-wallets-mobile-expo-starter/src/native-passkey.ts new file mode 100644 index 0000000..2b057e2 --- /dev/null +++ b/modular-wallets-mobile-expo-starter/src/native-passkey.ts @@ -0,0 +1,227 @@ +import type { Hex } from 'viem'; +import { hashMessage, hashTypedData } from 'viem'; +import type { WebAuthnAccount } from 'viem/account-abstraction'; +import { parseCredentialPublicKey, parsePublicKey, serializePublicKey } from 'webauthn-p256'; + +import { + type CircleModularWalletsNativeModule, + type CircleNativeCredential, + type CircleNativeRegistrationCredential, + type CircleNativeSignResult, + resolveCircleModularWalletsNativeModule, +} from '../modules/circle-modular-wallets'; + +type CircleNativeConnectionInput = { + clientKey: string; + clientUrl: string; + expectedRpId: string; +}; + +type CircleNativeRegisterInput = CircleNativeConnectionInput & { + userName: string; +}; + +type CircleNativeReconnectInput = CircleNativeConnectionInput & { + expectedCredentialId: string; +}; + +export type OfficialCircleNativeCredential = { + id: string; + publicKey: Hex; + rpId: string; + registration?: CircleNativeRegistrationCredential; +}; + +function requireNativeModule( + nativeModule: CircleModularWalletsNativeModule | null +): CircleModularWalletsNativeModule { + if (!nativeModule) { + throw new Error( + 'The official Circle Modular Wallets native module is not installed in this development build. Rebuild the Expo dev client before using wallets.' + ); + } + return nativeModule; +} + +function messageFromCause(cause: unknown): string { + if (cause instanceof Error && cause.message.trim()) { + return cause.message.trim(); + } + return 'Unknown native SDK failure'; +} + +function redactSensitiveValues(message: string, sensitiveValues: readonly string[]): string { + return sensitiveValues.reduce( + (sanitized, value) => (value ? sanitized.split(value).join('[redacted]') : sanitized), + message + ); +} + +async function callNative( + operation: string, + run: () => Promise, + sensitiveValues: readonly string[] = [] +): Promise { + try { + return await run(); + } catch (cause) { + // Native networking errors are outside our control and may echo request + // metadata. Never attach the original exception when it could contain the + // public-but-sensitive Client Key: error reporters recursively serialize + // `cause`, which would undo a redacted top-level message. + const sanitizedCause = redactSensitiveValues(messageFromCause(cause), sensitiveValues); + throw new Error(`Circle native ${operation} failed: ${sanitizedCause}`, { + cause: new Error(sanitizedCause), + }); + } +} + +function assertExpectedRpId(actual: string, expected: string): void { + if (actual !== expected) { + throw new Error( + `Circle Client Key passkey domain mismatch: the native SDK returned ${actual}, but this app requires ${expected}. Update the Client Key Web/passkey domain in Circle Console; no credential or UserOp was accepted.` + ); + } +} + +function bytesFromHex(value: string): Uint8Array { + const hex = value.startsWith('0x') ? value.slice(2) : value; + if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) { + throw new Error('Circle native SDK returned an invalid public key'); + } + return Uint8Array.from(hex.match(/.{2}/g)!.map(byte => Number.parseInt(byte, 16))); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return Uint8Array.from(bytes).buffer; +} + +function ensureHex(value: string, label: string): Hex { + const hex = value.startsWith('0x') ? value : `0x${value}`; + if (!/^0x[0-9a-f]+$/i.test(hex)) { + throw new Error(`Circle native SDK returned an invalid ${label}`); + } + return hex as Hex; +} + +async function normalizePublicKey(value: string): Promise { + const bytes = bytesFromHex(value); + const parsed = + bytes.length === 64 || bytes.length === 65 + ? parsePublicKey(bytes) + : await parseCredentialPublicKey(toArrayBuffer(bytes)); + return ensureHex(serializePublicKey(parsed, { compressed: true }), 'public key'); +} + +async function normalizeCredential( + credential: CircleNativeCredential, + expectedRpId: string +): Promise { + assertExpectedRpId(credential.rpId, expectedRpId); + return { + id: credential.id, + publicKey: await normalizePublicKey(credential.publicKey), + rpId: credential.rpId, + ...(credential.registration ? { registration: credential.registration } : {}), + }; +} + +export async function registerOfficialCircleNativeCredential( + input: CircleNativeRegisterInput, + nativeModule = resolveCircleModularWalletsNativeModule() +): Promise { + const module = requireNativeModule(nativeModule); + const credential = await callNative( + 'passkey registration', + () => module.register(input.clientKey, input.clientUrl, input.userName), + [input.clientKey] + ); + return normalizeCredential(credential, input.expectedRpId); +} + +export async function reconnectOfficialCircleNativeCredential( + input: CircleNativeReconnectInput, + nativeModule = resolveCircleModularWalletsNativeModule() +): Promise { + const module = requireNativeModule(nativeModule); + const credential = await callNative( + 'passkey reconnect', + () => module.login(input.clientKey, input.clientUrl), + [input.clientKey] + ); + const normalized = await normalizeCredential(credential, input.expectedRpId); + if (normalized.id !== input.expectedCredentialId) { + throw new Error( + `Circle native passkey reconnect selected credential ${normalized.id}, but this wallet is owned by ${input.expectedCredentialId}. Select the wallet owner passkey and retry.` + ); + } + return normalized; +} + +function decodeBase64Url(value: string): ArrayBuffer { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + const bytes: number[] = []; + + for (let index = 0; index < padded.length; index += 4) { + const chunk = padded.slice(index, index + 4); + const values = [...chunk].map(character => + character === '=' ? 0 : alphabet.indexOf(character) + ); + if (values.some(value => value < 0)) { + throw new Error('Circle native SDK returned invalid base64url assertion data'); + } + const packed = (values[0]! << 18) | (values[1]! << 12) | (values[2]! << 6) | values[3]!; + bytes.push((packed >> 16) & 0xff); + if (chunk[2] !== '=') bytes.push((packed >> 8) & 0xff); + if (chunk[3] !== '=') bytes.push(packed & 0xff); + } + + return Uint8Array.from(bytes).buffer; +} + +function toRawCredential(result: CircleNativeSignResult) { + return { + id: result.raw.id, + rawId: decodeBase64Url(result.raw.rawId), + type: 'public-key' as const, + authenticatorAttachment: result.raw.authenticatorAttachment ?? null, + response: { + clientDataJSON: decodeBase64Url(result.raw.response.clientDataJSON), + authenticatorData: decodeBase64Url(result.raw.response.authenticatorData), + signature: decodeBase64Url(result.raw.response.signature), + userHandle: result.raw.response.userHandle + ? decodeBase64Url(result.raw.response.userHandle) + : null, + }, + getClientExtensionResults: () => ({}), + }; +} + +export function createOfficialCircleNativeWebAuthnAccount( + credential: Pick, + nativeModule = resolveCircleModularWalletsNativeModule() +): WebAuthnAccount { + const module = requireNativeModule(nativeModule); + const sign: WebAuthnAccount['sign'] = async ({ hash }) => { + const result = await callNative('passkey signing', () => module.sign(hash)); + return { + signature: ensureHex(result.signature, 'signature'), + webauthn: { + ...result.webauthn, + authenticatorData: ensureHex(result.webauthn.authenticatorData, 'authenticator data'), + }, + raw: toRawCredential(result), + }; + }; + + return { + id: credential.id, + publicKey: credential.publicKey, + sign, + signMessage: ({ message }) => sign({ hash: hashMessage(message) }), + signTypedData: parameters => sign({ hash: hashTypedData(parameters) }), + type: 'webAuthn', + }; +} diff --git a/modular-wallets-mobile-expo-starter/src/wallet.ts b/modular-wallets-mobile-expo-starter/src/wallet.ts new file mode 100644 index 0000000..2568b78 --- /dev/null +++ b/modular-wallets-mobile-expo-starter/src/wallet.ts @@ -0,0 +1,14 @@ +import { toCircleSmartAccount } from '@circle-fin/modular-wallets-core'; +import { createOfficialCircleNativeWebAuthnAccount, type OfficialCircleNativeCredential } from './native-passkey'; + +// Supply a public RPC client configured for your Circle-supported network. +// Account creation calculates the address; it does not prove onchain deployment. +export async function createWallet( + credential: OfficialCircleNativeCredential, + client: Parameters[0]['client'], +) { + return toCircleSmartAccount({ + client, + owner: createOfficialCircleNativeWebAuthnAccount(credential), + }); +} From 2b89cbadf320b47608fb5a2fd74a331c5d0aec59 Mon Sep 17 00:00:00 2001 From: criptopoeta Date: Sat, 3 Oct 2026 16:10:20 -0300 Subject: [PATCH 2/3] Pin public key helper to source implementation version --- modular-wallets-mobile-expo-starter/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modular-wallets-mobile-expo-starter/package.json b/modular-wallets-mobile-expo-starter/package.json index 80d0be6..0afe5a4 100644 --- a/modular-wallets-mobile-expo-starter/package.json +++ b/modular-wallets-mobile-expo-starter/package.json @@ -9,7 +9,7 @@ "dependencies": { "@circle-fin/modular-wallets-core": "1.0.13", "viem": "2.38.3", - "webauthn-p256": "^0.0.10" + "webauthn-p256": "0.0.5" }, "peerDependencies": { "expo": "*", From 7720d6ac241f7ef1327acc0a355fe03d03e06c3e Mon Sep 17 00:00:00 2001 From: criptopoeta Date: Sat, 3 Oct 2026 17:13:42 -0300 Subject: [PATCH 3/3] Make Expo starter a standalone biometric passkey wallet --- README.md | 2 +- .../.env.example | 1 - .../.gitignore | 5 + modular-wallets-mobile-expo-starter/App.tsx | 247 + .../CONTENTS.txt | 9 +- modular-wallets-mobile-expo-starter/README.md | 106 +- .../app.config.example.ts | 8 - .../app.config.ts | 15 + modular-wallets-mobile-expo-starter/check.py | 4 +- modular-wallets-mobile-expo-starter/eas.json | 8 + modular-wallets-mobile-expo-starter/index.ts | 8 + .../CircleModularWalletsNativeModule.kt | 30 + .../modules/circle-modular-wallets/index.ts | 1 + .../CircleModularWalletsNativeModule.swift | 14 + .../package-lock.json | 7483 +++++++++++++++++ .../package.json | 32 +- .../src/native-passkey.ts | 36 +- .../src/wallet-input.ts | 46 + .../src/wallet.ts | 94 +- .../tests/wallet.test.ts | 90 + .../tsconfig.json | 16 + 21 files changed, 8151 insertions(+), 104 deletions(-) create mode 100644 modular-wallets-mobile-expo-starter/App.tsx delete mode 100644 modular-wallets-mobile-expo-starter/app.config.example.ts create mode 100644 modular-wallets-mobile-expo-starter/app.config.ts create mode 100644 modular-wallets-mobile-expo-starter/eas.json create mode 100644 modular-wallets-mobile-expo-starter/index.ts create mode 100644 modular-wallets-mobile-expo-starter/package-lock.json create mode 100644 modular-wallets-mobile-expo-starter/src/wallet-input.ts create mode 100644 modular-wallets-mobile-expo-starter/tests/wallet.test.ts create mode 100644 modular-wallets-mobile-expo-starter/tsconfig.json diff --git a/README.md b/README.md index 411b80c..104a2e6 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Run `npm run server` and `npm run dev` in separate terminals. ### [`modular-wallets-mobile-expo-starter`](./modular-wallets-mobile-expo-starter) -Source integration kit for native passkey registration, reconnect, and signing on Expo iOS and Android, plus a single-owner modular wallet adapter. Install into an existing Expo development build; see its README for native setup and outstanding physical-device acceptance checks. +A single-screen Expo testnet wallet for iOS and Android: create or reconnect a native passkey, unlock with Face ID / Touch ID / Android biometrics, receive test USDC, and review and approve a transfer. Includes the official Circle native bridge, app identity setup, and runnable checks; signed physical-device acceptance remains pending. ## License diff --git a/modular-wallets-mobile-expo-starter/.env.example b/modular-wallets-mobile-expo-starter/.env.example index d60909b..cacfe9e 100644 --- a/modular-wallets-mobile-expo-starter/.env.example +++ b/modular-wallets-mobile-expo-starter/.env.example @@ -1,3 +1,2 @@ EXPO_PUBLIC_CIRCLE_CLIENT_KEY=YOUR_RESTRICTED_TESTNET_CLIENT_KEY -EXPO_PUBLIC_CIRCLE_CLIENT_URL=https://modular-sdk.circle.com/v1/rpc/w3s/buidl EXPO_PUBLIC_WEBAUTHN_RP_ID=wallet.example.com diff --git a/modular-wallets-mobile-expo-starter/.gitignore b/modular-wallets-mobile-expo-starter/.gitignore index 98099d6..7357edc 100644 --- a/modular-wallets-mobile-expo-starter/.gitignore +++ b/modular-wallets-mobile-expo-starter/.gitignore @@ -8,3 +8,8 @@ Pods/ *.keystore *.mobileprovision .DS_Store +dist/ +ios/ +android/ +!modules/**/ios/ +!modules/**/android/ diff --git a/modular-wallets-mobile-expo-starter/App.tsx b/modular-wallets-mobile-expo-starter/App.tsx new file mode 100644 index 0000000..a5c53db --- /dev/null +++ b/modular-wallets-mobile-expo-starter/App.tsx @@ -0,0 +1,247 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, Alert, AppState, KeyboardAvoidingView, Linking, Platform, Pressable, ScrollView, StatusBar, StyleSheet, Text, TextInput, View } from 'react-native'; +import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; +import * as LocalAuthentication from 'expo-local-authentication'; +import * as SecureStore from 'expo-secure-store'; +import { isAddress, type Address, type Hex } from 'viem'; +import { resolveCircleModularWalletsNativeModule, type CircleModularWalletsNativeModule } from './modules/circle-modular-wallets'; +import { registerOfficialCircleNativeCredential, loginOfficialCircleNativeCredential, reconnectOfficialCircleNativeCredential, type OfficialCircleNativeCredential } from './src/native-passkey'; +import { createWalletSession, network, walletConfig, type WalletSession } from './src/wallet'; +import { normalizePublicKey, parseTransfer } from './src/wallet-input'; + +function isBackgrounded() { return AppState.currentState === 'background'; } +const storageKey = 'passkey-wallet-v1'; +type SavedWallet = { credential: OfficialCircleNativeCredential; address?: Address; pending?: Hex }; +const colors = { purple: '#6951ce', ink: '#352575', pale: '#f6f2ff', line: '#e4d8ff', mint: '#aeffea' }; + +function Button({ title, onPress, disabled = false, secondary = false }: { title: string; onPress: () => void; disabled?: boolean; secondary?: boolean }) { + return [styles.button, secondary && styles.secondary, (disabled || pressed) && styles.dim]}> + {title} + ; +} + +function WalletScreen() { + const [saved, setSaved] = useState(null); + const savedRef = useRef(null); + const [loaded, setLoaded] = useState(false); + const [session, setSession] = useState(null); + const [locked, setLocked] = useState(true); + const lockedRef = useRef(true); + const [covered, setCovered] = useState(false); + const [busy, setBusy] = useState(''); + const busyRef = useRef(false); + const nativePrompt = useRef(false); + const epoch = useRef(0); + const [authLabel, setAuthLabel] = useState('Biometrics'); + const [balance, setBalance] = useState('—'); + const [recipient, setRecipient] = useState(''); + const [amount, setAmount] = useState(''); + const [notice, setNotice] = useState(''); + const [error, setError] = useState(''); + const [transaction, setTransaction] = useState(); + + function lock() { + epoch.current += 1; + lockedRef.current = true; + setLocked(true); setSession(null); setBalance('—'); + setRecipient(''); setAmount(''); setNotice(''); setTransaction(undefined); setError(''); + } + + useEffect(() => { + async function load() { + try { + const encoded = await SecureStore.getItemAsync(storageKey); + if (encoded) { + const record = JSON.parse(encoded) as SavedWallet; + if (!record.credential?.id || !record.credential.rpId || (record.address && !isAddress(record.address)) + || (record.pending && !/^0x[0-9a-f]{64}$/i.test(record.pending))) throw new Error('Saved wallet data is invalid. Do not reset it; check your device backup.'); + record.credential.publicKey = normalizePublicKey(record.credential.publicKey); + savedRef.current = record; setSaved(record); + } + setLoaded(true); + const types = await LocalAuthentication.supportedAuthenticationTypesAsync(); + setAuthLabel(Platform.OS === 'ios' + ? types.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION) ? 'Face ID' : 'Touch ID' + : 'Biometrics'); + } catch { setError('Unable to load your saved wallet. Close and reopen the app; your passkey has not been removed.'); } + } + void load(); + const listener = AppState.addEventListener('change', state => { + // Hide immediately during app switching, including the OS passkey prompt. + setCovered(state !== 'active'); + if (state === 'background' && !nativePrompt.current) lock(); + }); + return () => listener.remove(); + }, []); + + async function save(record: SavedWallet) { + await SecureStore.setItemAsync(storageKey, JSON.stringify(record), { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }); + savedRef.current = record; setSaved(record); + } + + async function prompt(work: () => Promise) { + if (AppState.currentState !== 'active') throw new Error('Return to the app to approve.'); + nativePrompt.current = true; + try { return await work(); } + finally { nativePrompt.current = false; if (isBackgrounded()) lock(); } + } + + function moduleWithPrompt(): CircleModularWalletsNativeModule { + const native = resolveCircleModularWalletsNativeModule(); + if (!native) throw new Error('This requires a native development build. Expo Go is not supported.'); + return { + register: (...args) => prompt(() => native.register(...args)), + login: (...args) => prompt(() => native.login(...args)), + sign: (...args) => { + if (lockedRef.current) throw new Error('Unlock your wallet before approving.'); + return prompt(() => native.sign(...args)); + }, + rpcHeaders: () => native.rpcHeaders(), + }; + } + + async function run(label: string, work: () => Promise) { + if (busyRef.current) return; + busyRef.current = true; setBusy(label); setError(''); + try { await work(); } + catch (cause) { + if (lockedRef.current && label !== 'Connecting your passkey…' && label !== 'Reconnecting…') return; + const key = process.env.EXPO_PUBLIC_CIRCLE_CLIENT_KEY || ''; + const message = cause instanceof Error ? cause.message : 'Something went wrong. Please retry.'; + setError(key ? message.split(key).join('[redacted]') : message); + } finally { busyRef.current = false; setBusy(''); } + } + + async function connect(create: boolean) { + const config = walletConfig(); + const native = moduleWithPrompt(); + const currentEpoch = epoch.current; + if (create && savedRef.current) throw new Error('Reconnect your existing wallet instead.'); + if (!await LocalAuthentication.hasHardwareAsync() || !await LocalAuthentication.isEnrolledAsync()) { + throw new Error('Set up Face ID, Touch ID, or fingerprint unlock in your device settings first.'); + } + const authentication = await prompt(() => LocalAuthentication.authenticateAsync({ + promptMessage: 'Unlock your wallet', cancelLabel: 'Cancel', disableDeviceFallback: false, biometricsSecurityLevel: 'strong', + })); + if (!authentication.success) throw new Error('Unlock canceled. Your wallet stays locked.'); + if (epoch.current !== currentEpoch) return; + const stored = savedRef.current; + const credential = create + ? await registerOfficialCircleNativeCredential({ ...config, userName: 'My Wallet' }, native) + : stored + ? await reconnectOfficialCircleNativeCredential({ ...config, expectedCredentialId: stored.credential.id }, native) + : await loginOfficialCircleNativeCredential(config, native); + if (stored && normalizePublicKey(stored.credential.publicKey) !== credential.publicKey) throw new Error('The selected passkey public key does not match your saved wallet.'); + // Retain the new credential even if address creation or the RPC fails. Retry reconnects it. + if (!stored) await save({ credential: { id: credential.id, publicKey: credential.publicKey, rpId: credential.rpId } }); + if (epoch.current !== currentEpoch) return; + const next = await createWalletSession(credential, stored?.address, native); + if (stored?.address && next.account.address.toLowerCase() !== stored.address.toLowerCase()) throw new Error('The connected passkey belongs to a different wallet.'); + await save({ credential: { id: credential.id, publicKey: credential.publicKey, rpId: credential.rpId }, address: next.account.address, ...(stored?.pending ? { pending: stored.pending } : {}) }); + if (epoch.current !== currentEpoch || AppState.currentState !== 'active') return; + lockedRef.current = false; setLocked(false); setSession(next); + setNotice(stored?.pending ? 'A transfer is awaiting confirmation. Check its status before sending again.' : 'Your passkey is connected.'); + const initialBalance = await next.balance(); + if (!lockedRef.current && epoch.current === currentEpoch) setBalance(initialBalance); + } + + async function checkTransfer(next: WalletSession, record: SavedWallet) { + if (!record.pending) return; + const receiptEpoch = epoch.current; + const receipt = await next.receipt(record.pending); + await save({ credential: record.credential, address: record.address }); + if (!receipt.success) throw new Error('The transfer failed onchain. No successful transfer was recorded; you can review and retry.'); + if (!lockedRef.current && epoch.current === receiptEpoch) { + setTransaction(receipt.hash); setNotice('Transfer confirmed.'); + const refreshed = await next.balance(); + if (!lockedRef.current && epoch.current === receiptEpoch) setBalance(refreshed); + } + } + + function review() { + try { + const transfer = parseTransfer(recipient, amount); + Alert.alert('Review transfer', `Send ${amount.trim()} test USDC\n\nTo ${transfer.to}\n\nAvalanche Fuji • test funds only`, [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Approve with passkey', onPress: () => void run('Waiting for your approval…', async () => { + if (lockedRef.current || !session || !savedRef.current || savedRef.current.pending) throw new Error('Unlock the wallet and check any pending transfer first.'); + const sendingEpoch = epoch.current; + const activeSession = session; + const hash = await activeSession.send(recipient, amount, async hash => { + if (lockedRef.current || epoch.current !== sendingEpoch) throw new Error('Wallet locked. Review the transfer again.'); + const record = { ...savedRef.current!, pending: hash }; + await save(record); + if (lockedRef.current || epoch.current !== sendingEpoch) { + await save({ credential: record.credential, address: record.address }); + throw new Error('Wallet locked before submission. Review the transfer again.'); + } + setNotice('Operation prepared. Waiting for submission and confirmation…'); + }, async hash => { + const record = savedRef.current; + if (record?.pending === hash) await save({ credential: record.credential, address: record.address }); + if (!lockedRef.current) setNotice('Transfer rejected before submission. Review the error and retry.'); + }); + if (!lockedRef.current) setNotice('Transfer submitted. Waiting for confirmation…'); + await checkTransfer(activeSession, { ...savedRef.current!, pending: hash }); + setAmount(''); setRecipient(''); + }) }, + ]); + } catch (cause) { setError(cause instanceof Error ? cause.message : 'Check the recipient and amount.'); } + } + + return + + + + Passkey WalletTESTNET + {locked ? + ◎{saved ? 'Welcome back.' : 'Your wallet. Your passkey.'} + Unlock with {authLabel}. Approve transfers with your device’s secure passkey prompt. +