Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/passkey-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Passkey Diagnostics

Send can optionally run a "passkey health check" immediately after provisioning a new credential (or before other
critical actions). This feature helps catch OEMs with broken WebAuthn implementations before a user deposits funds.

## Feature Flag

The behaviour is controlled with the `NEXT_PUBLIC_PASSKEY_DIAGNOSTIC_MODE` environment variable:

- `disabled` *(default)* – diagnostics never run.
- `high-risk` – diagnostics run on devices whose user agent or device metadata indicates Android builds from
manufacturers with known WebAuthn bugs (Vivo, Oppo/OnePlus/Realme, Xiaomi/Redmi/Poco, Huawei/Honor, ZTE/Nubia,
Meizu, Lenovo/Motorola, Tecno/Infinix) or placeholder Android builds that hide the vendor.
- `always` – diagnostics run for every device.

### Logging control

- `NEXT_PUBLIC_PASSKEY_DIAGNOSTIC_LOGGING` – set to `enabled` to emit structured console logs describing the
environment signals (user agent, vendor fingerprint, heuristic matches) and the diagnostic lifecycle. Leave unset in
production unless debugging.

Devices that identify as high-risk vendors but report Google Mobile Services (GMS) availability are automatically
treated as healthy and skip the diagnostic—global-market firmware from those OEMs usually ships with GMS.

On the web/PWA surface we additionally probe
`PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()` (UVPAA). If the browser reports that no
platform authenticator is available, the app still invokes the dummy-sign diagnostic after onboarding to confirm the
device can safely sign transactions.

## Usage

The helper `runPasskeyDiagnostic` wraps the existing `signChallenge` flow with a throwaway challenge. Any failure is
reported with a `PasskeyDiagnosticError`, allowing flows to block onboarding or deposits until a healthy signer is
present. During onboarding, the app surfaces a “passkey integrity check” status indicator with friendly messaging,
including explicit retry affordances when the check fails, so the user understands why the extra biometric prompt
appears.

```
import { runPasskeyDiagnostic, shouldRunPasskeyDiagnostic, getPasskeyDiagnosticMode } from 'app/utils/passkeyDiagnostic'

const mode = getPasskeyDiagnosticMode()
if (await shouldRunPasskeyDiagnostic(mode)) {
const result = await runPasskeyDiagnostic({
allowedCredentials: [{ id: credentialId, userHandle: passkeyName }],
})

if (!result.success) {
throw new PasskeyDiagnosticError('Passkey health check failed', { cause: result.cause })
}
}
```

The same helper can be reused outside onboarding (e.g., before the first deposit or when adding backup signers) by
passing the appropriate `allowedCredentials` array.
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const TABS = [
},
]

export default function BottomNavBar({ currentRoute }: { currentRoute: string }) {
function BottomNavBar({ currentRoute }: { currentRoute: string }) {
const segments = useSegments()
const { direction } = useScrollDirection()
const translateY = useRef(new Animated.Value(0)).current
Expand Down Expand Up @@ -81,3 +81,7 @@ export default function BottomNavBar({ currentRoute }: { currentRoute: string })
</Animated.View>
)
}

BottomNavBar.displayName = 'BottomNavBar.native'

export default BottomNavBar
6 changes: 5 additions & 1 deletion packages/app/components/BottomTabBar/BottomNavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const TABS = [
},
]

export default function BottomNavBar({ currentRoute }: { currentRoute: string }) {
function BottomNavBar({ currentRoute }: { currentRoute: string }) {
const { direction } = useScrollDirection()
const { height } = useTabBarSize()

Expand All @@ -70,3 +70,7 @@ export default function BottomNavBar({ currentRoute }: { currentRoute: string })
</XStack>
)
}

BottomNavBar.displayName = 'BottomNavBar'

export default BottomNavBar
14 changes: 7 additions & 7 deletions packages/app/components/FormFields/BooleanCheckboxField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ export const BooleanCheckboxField = (
const themeName = (resolvedTheme ?? defaultTheme) as ThemeName

// Filter out props that shouldn't reach the DOM (e.g., enumValues from ts-react/form)
const { labelProps: labelPropsIn } =
(props as unknown as CheckboxProps & { enumValues?: unknown; labelProps?: LabelProps }) || {}
const checkboxProps = {
...(props as unknown as CheckboxProps & { enumValues?: unknown; labelProps?: LabelProps }),
}
// Remove non-DOM prop to avoid leaking to Checkbox/web
;(checkboxProps as { enumValues?: undefined }).enumValues = undefined
const {
labelProps: labelPropsIn,
enumValues: _enumValues,
...checkboxProps
} = (props as unknown as CheckboxProps & { enumValues?: unknown; labelProps?: LabelProps }) || {}

void _enumValues

const [isChecked, setIsChecked] = useState(checkboxProps.defaultChecked)

Expand Down
2 changes: 1 addition & 1 deletion packages/app/components/icons/IconMASQ.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { memo } from 'react'
import { Defs, G, Path, RadialGradient, Rect, Stop, Svg } from 'react-native-svg'

const Masq = (props) => {
const { size, color, ...rest } = props
const { size, ...rest } = props
return (
<Svg
width={size}
Expand Down
4 changes: 3 additions & 1 deletion packages/app/components/sidebar/HomeSideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const links = [
: undefined,
].filter(Boolean) as { icon: ReactElement; text: string; href: string }[]

const HomeSideBar = ({ ...props }: YStackProps) => {
function HomeSideBar({ ...props }: YStackProps) {
return (
<SideBar {...props} ai={'flex-start'} px="$7">
<YStack width={'100%'}>
Expand All @@ -100,6 +100,8 @@ const HomeSideBar = ({ ...props }: YStackProps) => {
)
}

HomeSideBar.displayName = 'HomeSideBar'

const DesktopAccountMenuEntry = () => {
const { profile, isLoadingProfile } = useUser()
const hoverStyles = useHoverStyles()
Expand Down
Loading
Loading