npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

decane-connect-kit-expo

v0.6.2

Published

Headless Expo/React Native SDK for Decane social sign-in wallets (TEE-backed, 2-of-3 Shamir shares).

Readme

decane-connect-kit-expo

Headless Expo / React Native SDK for Decane social sign-in wallets.

Same wallet, same backend, same TEE signer as the browser decane-connect-kit — no UI components, just functions. Recovery files written by either SDK open in the other.

The private key is never assembled on the device. A 2-of-3 Shamir split puts one share on the device (wrapped at rest), one on the server, and one in an optional recovery file; the enclave reconstructs the key internally, momentarily, per signature.


Install

npx expo install decane-connect-kit-expo expo-secure-store expo-crypto expo-local-authentication expo-web-browser
# optional, for the passkey unlock tier:
npx expo install react-native-passkeys

This requires a development build. expo-secure-store and the passkey module are native — they cannot load in Expo Go, and adding them over the air is not possible.

npx expo prebuild && npx expo run:ios   # or run:android

crypto.getRandomValues and Buffer are polyfilled by the SDK itself on import; you do not need react-native-get-random-values.


Quick start

import { createDecaneConnect } from "decane-connect-kit-expo";

const decane = await createDecaneConnect({
  appId: "proj_...",
  apiKey: "pk_...",
  chains: ["evm:8453", "solana:mainnet"],
  authMethods: ["email"],

  // "device" (default): the share is protected on this phone by the first unlock
  // tier that works. "identity": nothing is stored and nothing is asked for —
  // sign-in is the whole ceremony. See Protection tiers below.
  protection: "device",

  // Passkey tier — omit to fall back to biometrics/PIN (see Unlock tiers below)
  rpId: "app.example.com",
  rpName: "Example",

  // Required if the PIN tier can ever be reached
  promptPin: async () => showPinSheet(),

  // Recovery-file callbacks — see Recovery below
  onRecoveryShareOffer: async ({ evmAddress }) => askUserForBackupPassword(evmAddress),
  onRecoveryFileReady: async (file, filename) => saveToFiles(file, filename),
  onRecoveryRotated: async () => askUserForNewBackupPassword(),
  promptForRecoveryFile: async () => pickRecoveryFile(),
});

await decane.connectWithEmail("[email protected]");
const { addresses } = await decane.verifyEmailCode("[email protected]", "123456");

const txHash = await decane.sendTransaction({
  chain: "evm:8453",
  to: "0x...",
  value: 10_000_000_000_000n,
});

Everything is a plain async function — wire it into whatever state layer you already use.


Sign-in

Three routes, all available at once:

// 1. Email OTP — no native modules, no configuration
await decane.connectWithEmail(email);
await decane.verifyEmailCode(email, code);

// 2. Your own auth (Google/Apple/Firebase/Clerk/…) — recommended on mobile
const idToken = await yourGoogleSignIn();
await decane.connectWithToken(idToken);

// 3. Google via deep link, driven by the SDK
await decane.connectWithGoogle();

For (3), set redirectUri: "myapp://auth" in the config and register that exact URL as the API key's callback_url in the Decane dashboard. The backend only ever redirects to the registered value — it does not accept a redirect target supplied at call time — so a mismatch leaves the user stranded in the browser sheet.

connect() picks the first configured authMethods entry, but first tries a silent passkey sign-in when this device already holds a passkey-wrapped share.


Protection tiers

protection decides whether this app holds a piece of the wallet on the phone at all.

| | 'device' (default) | 'identity' | | --- | --- | --- | | At creation | first unlock tier that works (passkey → biometric keystore → PIN) | nothing asked, nothing stored | | Reopening the app | unlock with that tier | sign in again; the enclave provisions a fresh share | | Per-signature biometric | available on the passkey tier | not applicable | | Lost phone | sign in again | sign in again |

The security statement for the identity tier, verbatim: a wallet is exactly as safe as the user's identity-provider account plus Decane (the JWT signing key and the attested enclave). Anyone who can obtain a valid Decane session for the user can sign. There is no device-bound factor. Mitigations: a new-device email on every provision from an unseen device, a per-user freeze switch operated by Decane, and (follow-up) a fresh sign-in requirement for high-value operations.

The tier is a property of this client, not the wallet. Two apps on one project can hold the same wallet in different tiers — a marketplace with passkeys, a social app with none — same key, same addresses, same funds. The consequence: a per-signature assertion declared by one app protects that app's sessions, not the wallet; the other app's session signs without it.

Switching an app to 'identity' ports its users on their next sign-in with no loss of funds. An enrolled wallet (the normal case) simply provisions, with no prompt. A wallet that was never enrolled but has a wrapped share on this phone is unlocked with its existing tier one last time, enrolled, and never prompted again — the client emits migrated-to-identity so you can explain the one-off prompt. Nothing is re-split, rotated or moved. 'device' is the default and is today's behaviour exactly; nothing changes for an app that does not set protection.

In the identity tier, canUnlock(), hasPasskey() and canSignInWithPasskey() answer false, signInWithPasskey() and addPasskey() throw CallSequenceError, and getCapabilities().activeUnlockMethod is null.


Unlock tiers

These apply to protection: 'device'. The device share is encrypted at rest. Which key protects it depends on what the device can actually do — probed in order, first one that genuinely works wins:

| Tier | Key source | Passkey sign-in | Recovery on a new phone | Rotate / export | | --- | --- | --- | --- | --- | | passkey | WebAuthn PRF output | yes | passkey sync (Flow A) | yes | | secure-enclave | random key in Keychain/Keystore behind biometrics | no | recovery file only | no | | pin | PBKDF2 over a user PIN | no | recovery file only | no |

Override with unlockPreference: ["secure-enclave", "pin"] to skip a tier entirely.

const caps = await decane.getCapabilities();
// { passkeys, passkeyPrf, secureEnclave, activeUnlockMethod }

The probe for the passkey tier is a real ceremony, not a feature flag: a device that reports passkey support but returns no PRF output falls through to the next tier rather than ending up with a wallet nothing can unlock.

What the passkey tier needs

  • rpId set to a domain you control, with iOS associated domains (webcredentials:app.example.com) and an Android Digital Asset Links file.
  • OS-level PRF support — roughly iOS 18+ and Android 14+ with current Play Services. Narrower than on the web.
  • On Android, origin must be your android:apk-key-hash:<base64url sha256 of the signing cert>; it cannot be derived here. iOS uses https://<rpId>, which is the default.

A keystore key never leaves the device it was made on, but that no longer strands a new phone: every wallet is enrolled with the enclave at creation (creation blocks until it is — see WalletEnrolmentError), and signing in on a new phone provisions a fresh share. The recovery file is the offline fallback for when Decane is unreachable, not the way onto a new phone.


Recovery

// Rotate the share set and get a fresh recovery file (invalidates the old one)
const file = await decane.rotateShares({ password, passwordHint });

// Bearer backup: file + password alone controls the wallet. Explicit opt-in only.
const backup = await decane.exportPortableBackup({ password });

await decane.hasRecoveryShare();
await decane.shouldPromptRecovery(); // true only once the wallet holds a balance

Every recovery and every rotation re-splits the whole set, so the previous file dies immediately. onRecoveryRotated must capture the replacement — the SDK throws at construction if you enable rotateOnNewDeviceRecovery without it.

Parsing is exported separately, so you can validate a file the user picked and show its embedded addresses before committing:

import { parseRecoveryShareFile, decryptRecoveryShareFile } from "decane-connect-kit-expo";

Session

A session survives an app restart until its recorded expiry, then needs a fresh unlock. This differs from the browser SDK deliberately: sessionStorage dies with the tab, but backgrounding an app is normal, not a sign-out. Set persistSession: false for the stricter behaviour.

decane.isUnlocked();
decane.needsReconnect(); // known identity, dead session → show sign-in
decane.sessionExpiresAt();
await decane.unlock();

Security notes

Attestation cannot be fully verified on device. React Native has no WebAssembly, so the Intel DCAP quote verifier the browser SDK runs will not execute. On its own, this SDK only checks that the enclave reports the pinned measurement — which the enclave asserts about itself, and is not a cryptographic proof. Close the gap by forwarding the quote to your own backend:

verifyAttestation: async ({ quote, measurement, nonce }) => {
  const res = await fetch("https://your-api/verify-tee-quote", {
    method: "POST",
    body: JSON.stringify({ quote, measurement, nonce }),
  });
  if (!res.ok) throw new Error("TEE attestation rejected");
};

The SDK logs a warning once per process when no verifier is configured.

PBKDF2 is slow here. 600k SHA-256 iterations run in pure JS (there is no native PBKDF2 to call), costing seconds rather than milliseconds. This is paid only on PIN unlock and on recovery-file encrypt/decrypt — never on the passkey or biometric path, and never per signature. Show a spinner.

The identity tier has no device-bound factor. With protection: 'identity' a valid sign-in is the whole key; see Protection tiers for the statement and the mitigations. Do not choose it for an app where a stolen identity-provider session must not be enough to move funds.

Enrolment blocks creation, in every tier. A wallet the enclave has not sealed a share for cannot be provisioned to any other device, so creation now waits for it (three attempts) and throws WalletEnrolmentError — retryable, and nothing is left behind — rather than returning a wallet that only works on one phone. A 429 on provisioning is ProvisioningRateLimitedError, never NewDeviceError.

insecureSkipAttestation is development-only and removes the guarantee that you are talking to the audited enclave rather than an impostor holding your users' shares.


API

// Sign-in
connect() connectWithGoogle() connectWithEmail() verifyEmailCode() connectWithToken()
canSignInWithPasskey() signInWithPasskey() disconnect()

// Session
unlock() isUnlocked() needsReconnect() sessionExpiresAt() getAccessToken()

// Signing
signMessage() sendTransaction() signTypedData() signAuthorization()
signSolanaTransaction() signTronTransaction()

// State
getAddresses() getBalance() getBalances() getCapabilities()
recordTransaction() getTransactionHistory()

// Recovery
rotateShares() exportPortableBackup() hasRecoveryShare()
shouldPromptRecovery() dismissRecoveryPrompt() hasBackup() deleteBackup()

// Events: connected, disconnected, session-started, session-expired,
//         wallet-creating, wallet-unlocking
on() off()

Standalone exports usable without a client: startEmailAuth, startGoogleAuth, detectCapabilities, isPasskeySupported, isEnclaveAvailable, fetchBalance, formatBalanceDisplay, evmToTronAddress, clearDeviceState, and the recovery-file helpers.

getAccessToken() returns the Decane JWT for authenticating the user to your own backend — verify it against <apiBaseUrl>/.well-known/jwks.json.


Chains

evm:<chainId>, solana:mainnet | devnet, tron:mainnet | shasta | nile. EVM and Tron share one secp256k1 key; Solana uses the same seed on ed25519.

sendTransaction is EVM-only. Solana goes through signSolanaTransaction (you broadcast), Tron through signTronTransaction.


Differences from the browser SDK

| | Browser | Expo | | --- | --- | --- | | Protection tiers | protection: 'device' \| 'identity' | same | | Unlock (device tier) | passkey PRF, else password | passkey PRF → biometric keystore → PIN | | Crypto | WebCrypto | @noble (byte-compatible; PBKDF2 much slower) | | Storage | IndexedDB + local/sessionStorage | Keychain / Keystore | | Attestation | full DCAP verification | measurement pin + your verifyAttestation hook | | Google | page redirect | expo-web-browser + deep link | | rpId | defaults to hostname | must be configured | | UI | ships a wallet modal | none — headless only |