diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json
index e6941b57c..aa9031457 100644
--- a/apps/mobile/src/i18n/locales/en.json
+++ b/apps/mobile/src/i18n/locales/en.json
@@ -1059,6 +1059,28 @@
"title": "Transaction Request FAQ",
"body": "External applications, also called dApps, can initiate transaction requests independently from Pera Wallet. Please ensure you review these transactions carefully before approving and signing them.",
"warning": "All transactions are irreversible. As these requests are outside Pera Wallet's control, never approve transactions from a dApp you don't know."
+ },
+ "arc60_view": {
+ "title": "Sign-in Request",
+ "description": "Sign in to {{domain}} using your wallet. The signature proves you control this account but does not authorise any on-chain action.",
+ "domain": "Domain",
+ "scope": "Scope",
+ "scope_auth": "Authentication",
+ "request_id": "Request ID",
+ "on_behalf_of": "Signing With",
+ "siwa_statement": "Statement",
+ "siwa_uri": "URI",
+ "siwa_version": "Version",
+ "siwa_chain_id": "Chain ID",
+ "siwa_nonce": "Nonce",
+ "siwa_issued_at": "Issued At",
+ "siwa_expiration": "Expires",
+ "siwa_not_before": "Not Before",
+ "siwa_resources": "Resources",
+ "siwa_invalid": "This sign-in request is malformed and cannot be signed.",
+ "show_details": "Show Details",
+ "details_title": "Sign-in Request Details",
+ "details_description": "Review the full metadata of this sign-in request before approving."
}
},
"transactions": {
diff --git a/apps/mobile/src/modules/assets/components/AsaVerificationInfoBottomSheet/AsaVerificationInfoBottomSheet.tsx b/apps/mobile/src/modules/assets/components/AsaVerificationInfoBottomSheet/AsaVerificationInfoBottomSheet.tsx
index 45c0f4c9f..ad6abbe37 100644
--- a/apps/mobile/src/modules/assets/components/AsaVerificationInfoBottomSheet/AsaVerificationInfoBottomSheet.tsx
+++ b/apps/mobile/src/modules/assets/components/AsaVerificationInfoBottomSheet/AsaVerificationInfoBottomSheet.tsx
@@ -11,12 +11,12 @@
*/
import { useCallback } from 'react'
-import { ScrollView } from 'react-native'
import {
PWBottomSheet,
PWButton,
PWIcon,
PWImage,
+ PWScrollView,
PWText,
PWTouchableOpacity,
PWView,
@@ -71,7 +71,7 @@ export const AsaVerificationInfoBottomSheet = ({
/>
-
+
-
+
{t('asset_details.markets.no_clawback')}
}
*/}
-
+
)
}
diff --git a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/ImportAccountScreen.tsx b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/ImportAccountScreen.tsx
index 88e3c79f3..efc2dfff5 100644
--- a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/ImportAccountScreen.tsx
+++ b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/ImportAccountScreen.tsx
@@ -20,11 +20,12 @@ import {
PWIcon,
PWInput,
PWLoadingOverlay,
+ PWScrollView,
PWText,
PWView,
} from '@components/core'
-import { KeyboardAvoidingView, ScrollView } from 'react-native'
+import { KeyboardAvoidingView } from 'react-native'
import { useStyles } from './styles'
import { useImportAccountScreen } from './useImportAccountScreen'
import { useNavigationHeader } from '@hooks/useNavigationHeader'
@@ -76,7 +77,7 @@ export const ImportAccountScreen = () => {
return (
- {
)
})}
-
+
account.address === dataMessage.signer,
)
const styles = useStyles()
- const preferredIcon =
- request.sourceMetadata?.icons?.find(
- icon =>
- icon.endsWith('.png') ||
- icon.endsWith('.jpg') ||
- icon.endsWith('.jpeg'),
- ) ?? request.sourceMetadata?.icons?.at(0)
- const unnamedSource = t('signing.arbitrary_data_details.unnamed')
return (
- {preferredIcon ? (
-
- ) : (
-
-
-
- )}
-
- {t('signing.arbitrary_data_details.title', {
- name: request?.sourceMetadata?.name ?? unnamedSource,
- })}
-
{t('signing.arbitrary_data_details.description')}
-
@@ -137,7 +106,7 @@ export const ArbitraryDataSigningDetailsView = ({
-
+
)
}
diff --git a/apps/mobile/src/modules/signing/components/ArbitraryDataSigningView/__tests__/ArbitraryDataSigningView.spec.tsx b/apps/mobile/src/modules/signing/components/ArbitraryDataSigningView/__tests__/ArbitraryDataSigningView.spec.tsx
index 9ed51c7c8..acdbca65b 100644
--- a/apps/mobile/src/modules/signing/components/ArbitraryDataSigningView/__tests__/ArbitraryDataSigningView.spec.tsx
+++ b/apps/mobile/src/modules/signing/components/ArbitraryDataSigningView/__tests__/ArbitraryDataSigningView.spec.tsx
@@ -30,6 +30,10 @@ vi.mock('@components/core', () => ({
PWIcon: () => null,
PWImage: () => null,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ PWScrollView: ({ children, style }: any) => (
+
{children}
+ ),
PWTabView: {
createNavigator: () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningDetailsView.style.ts b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningDetailsView.style.ts
new file mode 100644
index 000000000..67c1b08f1
--- /dev/null
+++ b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningDetailsView.style.ts
@@ -0,0 +1,44 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+import { makeStyles } from '@rneui/themed'
+
+export const useStyles = makeStyles(theme => ({
+ container: {
+ flex: 1,
+ },
+ title: {
+ textAlign: 'center',
+ },
+ titleSection: {
+ alignItems: 'center',
+ gap: theme.spacing.md,
+ },
+ description: {
+ textAlign: 'center',
+ },
+ section: {
+ borderBottomWidth: theme.borders.sm,
+ borderBottomColor: theme.colors.layerGrayLightest,
+ paddingVertical: theme.spacing.lg,
+ gap: theme.spacing.md,
+ },
+ scrollContainer: {
+ flexGrow: 1,
+ },
+ resources: {
+ gap: theme.spacing.xs,
+ },
+ errorText: {
+ color: theme.colors.negative,
+ },
+}))
diff --git a/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningDetailsView.tsx b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningDetailsView.tsx
new file mode 100644
index 000000000..138758396
--- /dev/null
+++ b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningDetailsView.tsx
@@ -0,0 +1,157 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+import { PWText, PWView, PWScrollView } from '@components/core'
+import type { Arc60SignRequest, Siwa } from '@perawallet/wallet-core-signing'
+import type { WalletAccount } from '@perawallet/wallet-core-accounts'
+import { AccountDisplay } from '@modules/accounts/components/AccountDisplay'
+import { KeyValueRow } from '@components/KeyValueRow'
+import { useLanguage } from '@hooks/useLanguage'
+import type { Arc60ParsedPayload } from './parseArc60ForDisplay'
+import { useStyles } from './Arc60DataSigningDetailsView.style'
+
+export type Arc60DataSigningDetailsViewProps = {
+ request: Arc60SignRequest
+ account: WalletAccount | undefined
+ parsed: Arc60ParsedPayload
+}
+
+type SiwaField = {
+ label: string
+ value: string
+}
+
+const buildSiwaFields = (
+ siwa: Siwa,
+ t: (key: string) => string,
+): SiwaField[] => {
+ const fields: SiwaField[] = [
+ { label: t('signing.arc60_view.siwa_uri'), value: siwa.uri },
+ { label: t('signing.arc60_view.siwa_version'), value: siwa.version },
+ { label: t('signing.arc60_view.siwa_chain_id'), value: siwa.chain_id },
+ ]
+ if (siwa.nonce) {
+ fields.push({
+ label: t('signing.arc60_view.siwa_nonce'),
+ value: siwa.nonce,
+ })
+ }
+ if (siwa['issued-at']) {
+ fields.push({
+ label: t('signing.arc60_view.siwa_issued_at'),
+ value: siwa['issued-at'],
+ })
+ }
+ if (siwa['expiration-time']) {
+ fields.push({
+ label: t('signing.arc60_view.siwa_expiration'),
+ value: siwa['expiration-time'],
+ })
+ }
+ if (siwa['not-before']) {
+ fields.push({
+ label: t('signing.arc60_view.siwa_not_before'),
+ value: siwa['not-before'],
+ })
+ }
+ return fields
+}
+
+export const Arc60DataSigningDetailsView = ({
+ request,
+ account,
+ parsed,
+}: Arc60DataSigningDetailsViewProps) => {
+ const styles = useStyles()
+ const { t } = useLanguage()
+
+ const siwa = parsed.type === 'siwa' ? parsed.siwa : undefined
+ const parseError = parsed.type === 'error' ? parsed.message : undefined
+
+ return (
+
+
+
+ {t('signing.arc60_view.details_description')}
+
+
+
+
+
+ {request.stdSigData.domain}
+
+
+ {t('signing.arc60_view.scope_auth')}
+
+ {!!request.stdSigData.requestId && (
+
+ {request.stdSigData.requestId}
+
+ )}
+ {!!account && (
+
+
+
+ )}
+
+ {!!siwa && (
+
+ {!!siwa.statement && (
+
+ {siwa.statement}
+
+ )}
+ {buildSiwaFields(siwa, t).map(field => (
+
+ {field.value}
+
+ ))}
+ {!!siwa.resources?.length && (
+
+
+ {siwa.resources.map(resource => (
+
+ {resource}
+
+ ))}
+
+
+ )}
+
+ )}
+ {!!parseError && (
+
+
+ {t('signing.arc60_view.siwa_invalid')}
+
+ {parseError}
+
+ )}
+
+
+ )
+}
diff --git a/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningSummaryView.style.ts b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningSummaryView.style.ts
new file mode 100644
index 000000000..7e0d30ee1
--- /dev/null
+++ b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningSummaryView.style.ts
@@ -0,0 +1,50 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+import { makeStyles } from '@rneui/themed'
+
+export const useStyles = makeStyles(theme => ({
+ container: {
+ flexGrow: 1,
+ },
+ messageContainer: {
+ flexGrow: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: theme.spacing.md,
+ },
+ title: {
+ textAlign: 'center',
+ },
+ description: {
+ textAlign: 'center',
+ },
+ statementContainer: {
+ alignItems: 'center',
+ gap: theme.spacing.xs,
+ marginTop: theme.spacing.lg,
+ },
+ statementLabel: {
+ color: theme.colors.textGray,
+ },
+ accountContainer: {
+ alignItems: 'center',
+ gap: theme.spacing.md,
+ marginTop: theme.spacing.xxl,
+ },
+ onBehalfOf: {
+ textAlign: 'center',
+ },
+ detailsContainer: {
+ alignItems: 'flex-start',
+ },
+}))
diff --git a/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningSummaryView.tsx b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningSummaryView.tsx
new file mode 100644
index 000000000..c785f3fcf
--- /dev/null
+++ b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningSummaryView.tsx
@@ -0,0 +1,89 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+import { PWButton, PWText, PWView } from '@components/core'
+import type { Arc60SignRequest } from '@perawallet/wallet-core-signing'
+import type { WalletAccount } from '@perawallet/wallet-core-accounts'
+import { AccountDisplay } from '@modules/accounts/components/AccountDisplay'
+import { useLanguage } from '@hooks/useLanguage'
+import type { Arc60ParsedPayload } from './parseArc60ForDisplay'
+import { useStyles } from './Arc60DataSigningSummaryView.style'
+
+export type Arc60DataSigningSummaryViewProps = {
+ request: Arc60SignRequest
+ account: WalletAccount | undefined
+ parsed: Arc60ParsedPayload
+ onDetailsPress: () => void
+}
+
+export const Arc60DataSigningSummaryView = ({
+ request,
+ account,
+ parsed,
+ onDetailsPress,
+}: Arc60DataSigningSummaryViewProps) => {
+ const styles = useStyles()
+ const { t } = useLanguage()
+
+ const siwa = parsed.type === 'siwa' ? parsed.siwa : undefined
+
+ return (
+
+
+
+ {t('signing.arc60_view.title')}
+
+
+ {t('signing.arc60_view.description', {
+ domain: request.stdSigData.domain,
+ })}
+
+ {!!siwa?.statement && (
+
+
+ {t('signing.arc60_view.siwa_statement')}
+
+ {siwa.statement}
+
+ )}
+ {!!account && (
+
+
+ {t('signing.arc60_view.on_behalf_of')}
+
+
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningView.tsx b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningView.tsx
deleted file mode 100644
index ff79de2ac..000000000
--- a/apps/mobile/src/modules/signing/components/Arc60DataSigningView/Arc60DataSigningView.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- Copyright 2022-2025 Pera Wallet, LDA
- 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
- */
-
-import { PWButton, PWDivider, PWView } from '@components/core'
-import { EmptyView } from '@components/EmptyView'
-import {
- Arc60SignRequest,
- useSigningRequest,
-} from '@perawallet/wallet-core-signing'
-import { logger } from '@perawallet/wallet-core-shared'
-import { useStyles } from './styles'
-import { useLanguage } from '@hooks/useLanguage'
-
-export type Arc60DataSigningViewProps = {
- request: Arc60SignRequest
-}
-
-//TODO implement me
-export const Arc60DataSigningView = ({
- request,
-}: Arc60DataSigningViewProps) => {
- const styles = useStyles()
- const { t } = useLanguage()
- const { removeSignRequest } = useSigningRequest()
-
- const signAndSend = () => {
- logger.warn('Arc60 signing not implemented yet', request)
- removeSignRequest(request)
- }
-
- const rejectRequest = () => {
- removeSignRequest(request)
- if (request.transport === 'callback') {
- request.reject?.()
- }
- }
-
- return (
-
-
-
-
-
-
-
-
- )
-}
diff --git a/apps/mobile/src/modules/signing/components/Arc60DataSigningView/__tests__/Arc60DataSigningSummaryView.spec.tsx b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/__tests__/Arc60DataSigningSummaryView.spec.tsx
new file mode 100644
index 000000000..d17e169b5
--- /dev/null
+++ b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/__tests__/Arc60DataSigningSummaryView.spec.tsx
@@ -0,0 +1,110 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+import React from 'react'
+import { render, fireEvent } from '@test-utils/render'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { Arc60DataSigningSummaryView } from '../Arc60DataSigningSummaryView'
+import {
+ ARC60_SCOPE_AUTH,
+ SIWA_CHAIN_ID,
+ type Arc60SignRequest,
+ type Siwa,
+} from '@perawallet/wallet-core-signing'
+
+vi.mock('@components/core', () => ({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ PWView: ({ children, style }: any) => {children}
,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ PWText: ({ children }: any) => {children},
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ PWButton: ({ title, onPress }: any) => (
+
+ ),
+}))
+
+vi.mock('@modules/accounts/components/AccountDisplay', () => ({
+ AccountDisplay: () => account
,
+}))
+
+const validSiwa: Siwa = {
+ domain: 'arc60.io',
+ account_address: 'HD_ADDR',
+ uri: 'https://arc60.io/login',
+ version: '1',
+ chain_id: SIWA_CHAIN_ID,
+ type: 'ed25519',
+ statement: 'Sign in to Arc60',
+ nonce: 'nonce-123',
+}
+
+const request: Arc60SignRequest = {
+ id: '1',
+ type: 'arc60',
+ transport: 'callback',
+ stdSigData: {
+ data: 'eyJmb28iOiJiYXIifQ==',
+ signer: 'HD_ADDR',
+ domain: 'arc60.io',
+ authenticatorData: new Uint8Array(33),
+ requestId: 'req-1',
+ },
+ metadata: { scope: ARC60_SCOPE_AUTH, encoding: 'base64' },
+}
+
+describe('Arc60DataSigningSummaryView', () => {
+ const onDetailsPress = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('renders the title and description keys', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.textContent).toContain('signing.arc60_view.title')
+ expect(container.textContent).toContain(
+ 'signing.arc60_view.description',
+ )
+ })
+
+ it('renders the SIWA statement when present', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.textContent).toContain('Sign in to Arc60')
+ })
+
+ it('invokes onDetailsPress when Show Details is tapped', () => {
+ const { getByText } = render(
+ ,
+ )
+ fireEvent.click(getByText('signing.arc60_view.show_details'))
+ expect(onDetailsPress).toHaveBeenCalled()
+ })
+})
diff --git a/apps/mobile/src/modules/signing/components/Arc60DataSigningView/index.ts b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/index.ts
index fe577c140..6d1abe8b0 100644
--- a/apps/mobile/src/modules/signing/components/Arc60DataSigningView/index.ts
+++ b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/index.ts
@@ -10,4 +10,7 @@
limitations under the License
*/
-export { Arc60DataSigningView } from './Arc60DataSigningView'
+export { Arc60DataSigningSummaryView } from './Arc60DataSigningSummaryView'
+export { Arc60DataSigningDetailsView } from './Arc60DataSigningDetailsView'
+export { parseArc60ForDisplay } from './parseArc60ForDisplay'
+export type { Arc60ParsedPayload } from './parseArc60ForDisplay'
diff --git a/apps/mobile/src/modules/signing/components/Arc60DataSigningView/parseArc60ForDisplay.ts b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/parseArc60ForDisplay.ts
new file mode 100644
index 000000000..b0ba96d7d
--- /dev/null
+++ b/apps/mobile/src/modules/signing/components/Arc60DataSigningView/parseArc60ForDisplay.ts
@@ -0,0 +1,64 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+import {
+ type Siwa,
+ decodeArc60Data,
+ parseSiwa,
+} from '@perawallet/wallet-core-signing'
+
+export type Arc60ParsedPayload =
+ | { type: 'siwa'; siwa: Siwa }
+ | { type: 'error'; message: string }
+
+/**
+ * Decodes and parses an ARC-60 payload for human review.
+ * Returns the parsed SIWA struct on success, or a typed error message
+ * suitable for surfacing to the user — never throws.
+ */
+export const parseArc60ForDisplay = (
+ data: string,
+ encoding: string,
+): Arc60ParsedPayload => {
+ let bytes: Uint8Array
+ try {
+ bytes = decodeArc60Data(data, encoding)
+ } catch (error) {
+ return {
+ type: 'error',
+ message:
+ error instanceof Error
+ ? error.message
+ : 'Failed to decode data',
+ }
+ }
+ let jsonString: string
+ try {
+ jsonString = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
+ } catch {
+ return {
+ type: 'error',
+ message: 'Decoded payload is not valid UTF-8',
+ }
+ }
+ try {
+ return { type: 'siwa', siwa: parseSiwa(jsonString) }
+ } catch (error) {
+ return {
+ type: 'error',
+ message:
+ error instanceof Error
+ ? error.message
+ : 'Failed to parse SIWA payload',
+ }
+ }
+}
diff --git a/apps/mobile/src/modules/signing/components/MultipleArbitrarySignRequestView/MultipleArbitrarySignRequestView.tsx b/apps/mobile/src/modules/signing/components/MultipleArbitrarySignRequestView/MultipleArbitrarySignRequestView.tsx
index 6b82fd2f6..a1422be1b 100644
--- a/apps/mobile/src/modules/signing/components/MultipleArbitrarySignRequestView/MultipleArbitrarySignRequestView.tsx
+++ b/apps/mobile/src/modules/signing/components/MultipleArbitrarySignRequestView/MultipleArbitrarySignRequestView.tsx
@@ -10,13 +10,12 @@
limitations under the License
*/
-import { PWButton, PWText, PWView } from '@components/core'
+import { PWButton, PWScrollView, PWText, PWView } from '@components/core'
import type { PeraArbitraryDataMessage } from '@perawallet/wallet-core-signing'
import { useAllAccounts } from '@perawallet/wallet-core-accounts'
import { AccountDisplay } from '@modules/accounts/components/AccountDisplay'
import { useLanguage } from '@hooks/useLanguage'
import { useStyles } from './styles'
-import { ScrollView } from 'react-native-gesture-handler'
export type MultipleArbitrarySignRequestViewProps = {
requests: PeraArbitraryDataMessage[]
@@ -32,7 +31,7 @@ export const MultipleArbitrarySignRequestView = ({
const accounts = useAllAccounts()
return (
-
@@ -62,6 +61,6 @@ export const MultipleArbitrarySignRequestView = ({
/>
))}
-
+
)
}
diff --git a/apps/mobile/src/modules/signing/components/SignRequestView/SignRequestView.tsx b/apps/mobile/src/modules/signing/components/SignRequestView/SignRequestView.tsx
index 9a12fb015..5879f88e6 100644
--- a/apps/mobile/src/modules/signing/components/SignRequestView/SignRequestView.tsx
+++ b/apps/mobile/src/modules/signing/components/SignRequestView/SignRequestView.tsx
@@ -10,48 +10,83 @@
limitations under the License
*/
-import { Arc60SignRequest, SignRequest } from '@perawallet/wallet-core-signing'
+import { SignRequest } from '@perawallet/wallet-core-signing'
import { EmptyView } from '@components/EmptyView'
-import { Arc60DataSigningView } from '../Arc60DataSigningView'
import { useLanguage } from '@hooks/useLanguage'
import { SigningRoutes } from '@modules/signing/routes'
import {
NavigationContainer,
NavigationIndependentTree,
} from '@react-navigation/native'
-import { PWView } from '@components/core'
+import { PWButton, PWView } from '@components/core'
+import { BaseErrorBoundary } from '@components/BaseErrorBoundary'
+import { AppError, ErrorCategory } from '@perawallet/wallet-core-shared'
import { useStyles } from './styles'
export type SignRequestViewProps = {
request: SignRequest
}
+const SignRequestErrorFallback = ({
+ error,
+ reset,
+}: {
+ error: AppError | Error
+ reset: () => void
+}) => {
+ const { t } = useLanguage()
+ const appError = error instanceof AppError ? error : null
+ return (
+
+ }
+ />
+ )
+}
+
export const SignRequestView = ({ request }: SignRequestViewProps) => {
const { t } = useLanguage()
const styles = useStyles()
- switch (request.type) {
- case 'transactions':
- case 'arbitrary-data':
- return (
-
-
-
-
-
-
-
- )
- case 'arc60':
- return (
-
- )
- default:
- return (
-
- )
+ const isSupported =
+ request.type === 'transactions' ||
+ request.type === 'arbitrary-data' ||
+ request.type === 'arc60'
+
+ if (!isSupported) {
+ return (
+
+ )
}
+
+ return (
+ (
+
+ )}
+ >
+
+
+
+
+
+
+
+
+ )
}
diff --git a/apps/mobile/src/modules/signing/components/SignRequestView/__tests__/SignRequestView.spec.tsx b/apps/mobile/src/modules/signing/components/SignRequestView/__tests__/SignRequestView.spec.tsx
index 2dc3d0a54..a59287aa5 100644
--- a/apps/mobile/src/modules/signing/components/SignRequestView/__tests__/SignRequestView.spec.tsx
+++ b/apps/mobile/src/modules/signing/components/SignRequestView/__tests__/SignRequestView.spec.tsx
@@ -21,14 +21,11 @@ vi.mock('@modules/signing/routes', () => ({
return TransactionSigningView
if (request.type === 'arbitrary-data')
return ArbitraryDataSigningView
+ if (request.type === 'arc60') return Arc60SigningView
return null
},
}))
-vi.mock('../../Arc60DataSigningView', () => ({
- Arc60DataSigningView: () => Arc60DataSigningView
,
-}))
-
describe('SigningView', () => {
it('renders TransactionSigningView for transaction requests', () => {
const request = {
@@ -56,7 +53,7 @@ describe('SigningView', () => {
} as unknown as SignRequest
const { container } = render()
- expect(container.textContent).toContain('Arc60DataSigningView')
+ expect(container.textContent).toContain('Arc60SigningView')
})
it('renders empty view for unknown request types', () => {
diff --git a/apps/mobile/src/modules/signing/components/SingleArbitrarySignRequestView/SingleArbitrarySignRequestView.tsx b/apps/mobile/src/modules/signing/components/SingleArbitrarySignRequestView/SingleArbitrarySignRequestView.tsx
index a744478f3..0f6778c6b 100644
--- a/apps/mobile/src/modules/signing/components/SingleArbitrarySignRequestView/SingleArbitrarySignRequestView.tsx
+++ b/apps/mobile/src/modules/signing/components/SingleArbitrarySignRequestView/SingleArbitrarySignRequestView.tsx
@@ -38,7 +38,7 @@ export const SingleArbitrarySignRequestView = ({
{t('signing.arbitrary_data_view.body')}
diff --git a/apps/mobile/src/modules/signing/routes/SigningRoutes.tsx b/apps/mobile/src/modules/signing/routes/SigningRoutes.tsx
index 43355b077..94137b17a 100644
--- a/apps/mobile/src/modules/signing/routes/SigningRoutes.tsx
+++ b/apps/mobile/src/modules/signing/routes/SigningRoutes.tsx
@@ -27,6 +27,8 @@ import {
GroupDetailScreen,
ArbitraryDataSigningScreen,
ArbitraryDataSigningDetailsScreen,
+ Arc60SigningScreen,
+ Arc60SigningDetailsScreen,
} from '@modules/signing/screens'
import { SettingsSecurityScreen } from '@modules/settings/screens/SettingsSecurityScreen'
import { NavigationHeader } from '@components/NavigationHeader'
@@ -48,6 +50,10 @@ const getInitialRouteConfig = (request: SignRequest): InitialRouteConfig => {
return { name: 'ArbitraryDataSigning' }
}
+ if (request.type === 'arc60') {
+ return { name: 'Arc60Signing' }
+ }
+
const txRequest = request as TransactionSignRequest
const isSingleTransaction = txRequest.txs.length === 1
@@ -108,13 +114,23 @@ export const SigningRoutes = ({ request }: SigningRoutesProps) => {
+
+
)
}
diff --git a/apps/mobile/src/modules/signing/routes/types.ts b/apps/mobile/src/modules/signing/routes/types.ts
index bc0cbd540..e3b1e69ad 100644
--- a/apps/mobile/src/modules/signing/routes/types.ts
+++ b/apps/mobile/src/modules/signing/routes/types.ts
@@ -26,6 +26,8 @@ export type SigningStackParamList = {
SecuritySettings: undefined
ArbitraryDataSigning: undefined
ArbitraryDataSigningDetails: { message: PeraArbitraryDataMessage }
+ Arc60Signing: undefined
+ Arc60SigningDetails: undefined
}
export type SigningStackScreenProps =
diff --git a/apps/mobile/src/modules/signing/screens/ArbitraryDataSigningScreen/useArbitraryDataSigningScreen.ts b/apps/mobile/src/modules/signing/screens/ArbitraryDataSigningScreen/useArbitraryDataSigningScreen.ts
index a9822ffb7..d12cb9167 100644
--- a/apps/mobile/src/modules/signing/screens/ArbitraryDataSigningScreen/useArbitraryDataSigningScreen.ts
+++ b/apps/mobile/src/modules/signing/screens/ArbitraryDataSigningScreen/useArbitraryDataSigningScreen.ts
@@ -10,7 +10,7 @@
limitations under the License
*/
-import { useCallback } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import { useNavigation } from '@react-navigation/native'
import type { StackNavigationProp } from '@react-navigation/stack'
import {
@@ -43,7 +43,17 @@ export const useArbitraryDataSigningScreen =
const isSingleSignRequest = request?.data.length === 1
+ // Local optimistic flag: flips true the instant the user taps Confirm
+ // so the spinner is visible immediately, before the actor's stage
+ // transition propagates through the React subscription.
+ const [isApproving, setIsApproving] = useState(false)
+
+ useEffect(() => {
+ if (!pipeline.isLoading) setIsApproving(false)
+ }, [pipeline.isLoading])
+
const handleApprove = useCallback(() => {
+ setIsApproving(true)
pipeline.next()
}, [pipeline])
@@ -61,7 +71,7 @@ export const useArbitraryDataSigningScreen =
return {
request,
isSingleSignRequest,
- isPending: pipeline.isLoading,
+ isPending: pipeline.isLoading || isApproving,
handleApprove,
handleReject,
handleDetailsPress,
diff --git a/apps/mobile/src/modules/signing/screens/Arc60SigningDetailsScreen/Arc60SigningDetailsScreen.tsx b/apps/mobile/src/modules/signing/screens/Arc60SigningDetailsScreen/Arc60SigningDetailsScreen.tsx
new file mode 100644
index 000000000..7393c8705
--- /dev/null
+++ b/apps/mobile/src/modules/signing/screens/Arc60SigningDetailsScreen/Arc60SigningDetailsScreen.tsx
@@ -0,0 +1,54 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+import { useMemo } from 'react'
+import { PWView } from '@components/core'
+import {
+ type Arc60SignRequest,
+ useSigningRequest,
+} from '@perawallet/wallet-core-signing'
+import { useFindAccountByAddress } from '@perawallet/wallet-core-accounts'
+import {
+ Arc60DataSigningDetailsView,
+ parseArc60ForDisplay,
+} from '@modules/signing/components/Arc60DataSigningView'
+import { useStyles } from './styles'
+
+export const Arc60SigningDetailsScreen = () => {
+ const styles = useStyles()
+ const { currentRequest } = useSigningRequest()
+ const request = currentRequest as Arc60SignRequest | undefined
+
+ const account = useFindAccountByAddress(request?.stdSigData.signer ?? '')
+ const parsed = useMemo(
+ () =>
+ request
+ ? parseArc60ForDisplay(
+ request.stdSigData.data,
+ request.metadata.encoding,
+ )
+ : null,
+ [request],
+ )
+
+ if (!request || !parsed) return null
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/mobile/src/modules/signing/screens/Arc60SigningDetailsScreen/index.ts b/apps/mobile/src/modules/signing/screens/Arc60SigningDetailsScreen/index.ts
new file mode 100644
index 000000000..497986b27
--- /dev/null
+++ b/apps/mobile/src/modules/signing/screens/Arc60SigningDetailsScreen/index.ts
@@ -0,0 +1,13 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+export { Arc60SigningDetailsScreen } from './Arc60SigningDetailsScreen'
diff --git a/apps/mobile/src/modules/signing/components/Arc60DataSigningView/styles.ts b/apps/mobile/src/modules/signing/screens/Arc60SigningDetailsScreen/styles.ts
similarity index 56%
rename from apps/mobile/src/modules/signing/components/Arc60DataSigningView/styles.ts
rename to apps/mobile/src/modules/signing/screens/Arc60SigningDetailsScreen/styles.ts
index c5b4ff038..e6c05d437 100644
--- a/apps/mobile/src/modules/signing/components/Arc60DataSigningView/styles.ts
+++ b/apps/mobile/src/modules/signing/screens/Arc60SigningDetailsScreen/styles.ts
@@ -12,22 +12,9 @@
import { makeStyles } from '@rneui/themed'
-export const useStyles = makeStyles(theme => {
- return {
- container: {
- flex: 1,
- minHeight: 500,
- },
- buttonContainer: {
- flexDirection: 'row',
- gap: theme.spacing.lg,
- alignItems: 'center',
- justifyContent: 'center',
- paddingVertical: theme.spacing.sm,
- paddingHorizontal: theme.spacing.xl,
- },
- button: {
- flexGrow: 1,
- },
- }
-})
+export const useStyles = makeStyles(theme => ({
+ container: {
+ flex: 1,
+ padding: theme.spacing.xl,
+ },
+}))
diff --git a/apps/mobile/src/modules/signing/screens/Arc60SigningScreen/Arc60SigningScreen.tsx b/apps/mobile/src/modules/signing/screens/Arc60SigningScreen/Arc60SigningScreen.tsx
new file mode 100644
index 000000000..59cdc63a8
--- /dev/null
+++ b/apps/mobile/src/modules/signing/screens/Arc60SigningScreen/Arc60SigningScreen.tsx
@@ -0,0 +1,94 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+import { PWButton, PWText, PWView } from '@components/core'
+import { Arc60DataSigningSummaryView } from '@modules/signing/components/Arc60DataSigningView'
+import { EmptyView } from '@components/EmptyView'
+import { useLanguage } from '@hooks/useLanguage'
+import { useStyles } from './styles'
+import { useArc60SigningScreen } from './useArc60SigningScreen'
+
+export const Arc60SigningScreen = () => {
+ const styles = useStyles()
+ const { t } = useLanguage()
+ const {
+ request,
+ account,
+ parsed,
+ isPending,
+ canConfirm,
+ error,
+ handleApprove,
+ handleReject,
+ handleDetailsPress,
+ } = useArc60SigningScreen()
+
+ if (!request || !parsed) return null
+
+ if (parsed.type === 'error') {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {!!error && (
+ {error.message}
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/mobile/src/modules/signing/screens/Arc60SigningScreen/index.ts b/apps/mobile/src/modules/signing/screens/Arc60SigningScreen/index.ts
new file mode 100644
index 000000000..b844fff41
--- /dev/null
+++ b/apps/mobile/src/modules/signing/screens/Arc60SigningScreen/index.ts
@@ -0,0 +1,13 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+export { Arc60SigningScreen } from './Arc60SigningScreen'
diff --git a/apps/mobile/src/modules/signing/screens/Arc60SigningScreen/styles.ts b/apps/mobile/src/modules/signing/screens/Arc60SigningScreen/styles.ts
new file mode 100644
index 000000000..1405b6f3c
--- /dev/null
+++ b/apps/mobile/src/modules/signing/screens/Arc60SigningScreen/styles.ts
@@ -0,0 +1,44 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+import { makeStyles } from '@rneui/themed'
+
+export const useStyles = makeStyles(theme => ({
+ container: {
+ flex: 1,
+ paddingHorizontal: theme.spacing.xl,
+ },
+ contentContainer: {
+ flex: 1,
+ backgroundColor: theme.colors.background,
+ },
+ bodyContainer: {
+ flexGrow: 1,
+ },
+ errorText: {
+ color: theme.colors.negative,
+ textAlign: 'center',
+ paddingVertical: theme.spacing.sm,
+ },
+ buttonContainer: {
+ flexDirection: 'row',
+ gap: theme.spacing.lg,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderTopWidth: theme.borders.sm,
+ borderTopColor: theme.colors.layerGrayLightest,
+ paddingVertical: theme.spacing.md,
+ },
+ button: {
+ flexGrow: 1,
+ },
+}))
diff --git a/apps/mobile/src/modules/signing/screens/Arc60SigningScreen/useArc60SigningScreen.ts b/apps/mobile/src/modules/signing/screens/Arc60SigningScreen/useArc60SigningScreen.ts
new file mode 100644
index 000000000..a2bd87d6d
--- /dev/null
+++ b/apps/mobile/src/modules/signing/screens/Arc60SigningScreen/useArc60SigningScreen.ts
@@ -0,0 +1,98 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useNavigation } from '@react-navigation/native'
+import type { StackNavigationProp } from '@react-navigation/stack'
+import {
+ type Arc60SignRequest,
+ useSigningPipeline,
+} from '@perawallet/wallet-core-signing'
+import {
+ useFindAccountByAddress,
+ type WalletAccount,
+} from '@perawallet/wallet-core-accounts'
+import {
+ parseArc60ForDisplay,
+ type Arc60ParsedPayload,
+} from '@modules/signing/components/Arc60DataSigningView'
+import type { SigningStackParamList } from '@modules/signing/routes'
+
+type NavigationProp = StackNavigationProp
+
+type UseArc60SigningScreenResult = {
+ request: Arc60SignRequest | null
+ account: WalletAccount | undefined
+ parsed: Arc60ParsedPayload | null
+ isPending: boolean
+ canConfirm: boolean
+ error: Error | null
+ handleApprove: () => void
+ handleReject: () => void
+ handleDetailsPress: () => void
+}
+
+export const useArc60SigningScreen = (): UseArc60SigningScreenResult => {
+ const navigation = useNavigation()
+ const pipeline = useSigningPipeline()
+ const request =
+ (pipeline.currentRequest as Arc60SignRequest | undefined) ?? null
+
+ const account = useFindAccountByAddress(request?.stdSigData.signer ?? '')
+ const parsed = useMemo(
+ () =>
+ request
+ ? parseArc60ForDisplay(
+ request.stdSigData.data,
+ request.metadata.encoding,
+ )
+ : null,
+ [request],
+ )
+
+ // Local optimistic flag: flips true the instant the user taps Confirm so
+ // the spinner is visible immediately, before the actor's stage transition
+ // propagates through the React subscription.
+ const [isApproving, setIsApproving] = useState(false)
+
+ useEffect(() => {
+ if (!pipeline.isLoading) setIsApproving(false)
+ }, [pipeline.isLoading])
+
+ const handleApprove = useCallback(() => {
+ setIsApproving(true)
+ pipeline.next()
+ }, [pipeline])
+
+ const handleReject = useCallback(() => {
+ pipeline.fail()
+ }, [pipeline])
+
+ const handleDetailsPress = useCallback(() => {
+ navigation.navigate('Arc60SigningDetails')
+ }, [navigation])
+
+ const isPending = pipeline.isLoading || isApproving
+ const canConfirm = !isPending && !!account && parsed?.type === 'siwa'
+
+ return {
+ request,
+ account: account ?? undefined,
+ parsed,
+ isPending,
+ canConfirm,
+ error: pipeline.error,
+ handleApprove,
+ handleReject,
+ handleDetailsPress,
+ }
+}
diff --git a/apps/mobile/src/modules/signing/screens/SingleTransactionScreen/SingleTransactionScreen.tsx b/apps/mobile/src/modules/signing/screens/SingleTransactionScreen/SingleTransactionScreen.tsx
index ee3350c37..4d7fdfcf0 100644
--- a/apps/mobile/src/modules/signing/screens/SingleTransactionScreen/SingleTransactionScreen.tsx
+++ b/apps/mobile/src/modules/signing/screens/SingleTransactionScreen/SingleTransactionScreen.tsx
@@ -10,11 +10,10 @@
limitations under the License
*/
-import { PWDivider, PWView } from '@components/core'
+import { PWDivider, PWScrollView, PWView } from '@components/core'
import { EmptyView } from '@components/EmptyView'
import { useTheme } from '@rneui/themed'
import { useLanguage } from '@hooks/useLanguage'
-import { ScrollView } from 'react-native-gesture-handler'
import { TransactionSummaryHeader } from '@modules/signing/components/TransactionSummaryHeader'
import { FeeDisplay } from '@modules/signing/components/FeeDisplay'
import { SigningWarnings } from '@modules/signing/components/SigningWarnings'
@@ -48,7 +47,7 @@ export const SingleTransactionScreen = () => {
}
return (
-
+
{
-
+
)
}
diff --git a/apps/mobile/src/modules/signing/screens/TransactionDetailsScreen/TransactionDetailsScreen.tsx b/apps/mobile/src/modules/signing/screens/TransactionDetailsScreen/TransactionDetailsScreen.tsx
index bb2147181..678c2c338 100644
--- a/apps/mobile/src/modules/signing/screens/TransactionDetailsScreen/TransactionDetailsScreen.tsx
+++ b/apps/mobile/src/modules/signing/screens/TransactionDetailsScreen/TransactionDetailsScreen.tsx
@@ -10,7 +10,6 @@
limitations under the License
*/
-import { ScrollView } from 'react-native-gesture-handler'
import {
useNavigation,
useRoute,
@@ -30,6 +29,7 @@ import { TransactionDisplay } from '@modules/transactions/components/Transaction
import { GroupTransactionsPanel } from '@modules/transactions/components/transaction-details'
import type { SigningStackParamList } from '@modules/signing/routes'
import { useStyles } from './styles'
+import { PWScrollView } from '@components/core'
type NavigationProp = StackNavigationProp<
SigningStackParamList,
@@ -74,7 +74,7 @@ export const TransactionDetailsScreen = () => {
if (transaction) {
return (
-
+
{
onGroupTransactionPress={handleGroupTransactionPress}
/>
)}
-
+
)
}
diff --git a/apps/mobile/src/modules/signing/screens/index.ts b/apps/mobile/src/modules/signing/screens/index.ts
index 4ab4ef7e8..677f808c7 100644
--- a/apps/mobile/src/modules/signing/screens/index.ts
+++ b/apps/mobile/src/modules/signing/screens/index.ts
@@ -16,3 +16,5 @@ export * from './TransactionListScreen'
export * from './GroupDetailScreen'
export * from './ArbitraryDataSigningScreen'
export * from './ArbitraryDataSigningDetailsScreen'
+export * from './Arc60SigningScreen'
+export * from './Arc60SigningDetailsScreen'
diff --git a/apps/mobile/src/modules/transactions/screens/send-funds/ExpressSendScreen/ExpressSendScreen.tsx b/apps/mobile/src/modules/transactions/screens/send-funds/ExpressSendScreen/ExpressSendScreen.tsx
index fa57f13f1..6b0f89636 100644
--- a/apps/mobile/src/modules/transactions/screens/send-funds/ExpressSendScreen/ExpressSendScreen.tsx
+++ b/apps/mobile/src/modules/transactions/screens/send-funds/ExpressSendScreen/ExpressSendScreen.tsx
@@ -10,9 +10,14 @@
limitations under the License
*/
-import { PWButton, PWIcon, PWText, PWView } from '@components/core'
+import {
+ PWButton,
+ PWIcon,
+ PWScrollView,
+ PWText,
+ PWView,
+} from '@components/core'
import { useLanguage } from '@hooks/useLanguage'
-import { ScrollView } from 'react-native'
import { useStyles } from './styles'
import { useExpressSendScreen } from './useExpressSendScreen'
@@ -29,7 +34,7 @@ export const ExpressSendScreen = () => {
return (
-
+
{
{t(stepKey)}
))}
-
+
({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
PWText: vi.fn(({ children }: any) => {children}),
PWIcon: vi.fn(() => ),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ PWScrollView: vi.fn(({ children }: any) => {children}
),
}))
vi.mock('../useExpressSendScreen', () => ({
diff --git a/packages/accounts/src/__tests__/bip44.spec.ts b/packages/accounts/src/__tests__/bip44.spec.ts
new file mode 100644
index 000000000..775df54cf
--- /dev/null
+++ b/packages/accounts/src/__tests__/bip44.spec.ts
@@ -0,0 +1,143 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+import { describe, expect, test } from 'vitest'
+import {
+ ALGORAND_COIN_TYPE,
+ assertAlgorandBip44PathMatches,
+ hdPathMatchesDetails,
+ InvalidBip44PathError,
+ parseAlgorandBip44Path,
+} from '../bip44'
+import { DerivationTypes, type HDWalletDetails } from '../models'
+
+const details: HDWalletDetails = {
+ account: 0,
+ change: 0,
+ keyIndex: 3,
+ derivationType: DerivationTypes.Peikert,
+}
+
+describe('parseAlgorandBip44Path', () => {
+ test('parses a canonical Algorand BIP44 path with apostrophe hardening', () => {
+ expect(parseAlgorandBip44Path("m/44'/283'/0'/0/3")).toEqual({
+ account: 0,
+ change: 0,
+ keyIndex: 3,
+ })
+ })
+
+ test('accepts the `h` hardened marker', () => {
+ expect(parseAlgorandBip44Path('m/44h/283h/7h/1/9')).toEqual({
+ account: 7,
+ change: 1,
+ keyIndex: 9,
+ })
+ })
+
+ test('accepts the leading `m/` being omitted', () => {
+ expect(parseAlgorandBip44Path("44'/283'/2'/0/0")).toEqual({
+ account: 2,
+ change: 0,
+ keyIndex: 0,
+ })
+ })
+
+ test('rejects paths with the wrong segment count', () => {
+ expect(() => parseAlgorandBip44Path("m/44'/283'/0'")).toThrow(
+ InvalidBip44PathError,
+ )
+ })
+
+ test('rejects paths with a non-44 purpose', () => {
+ expect(() => parseAlgorandBip44Path("m/49'/283'/0'/0/0")).toThrow(
+ InvalidBip44PathError,
+ )
+ })
+
+ test('rejects paths with a non-Algorand coin type', () => {
+ expect(() => parseAlgorandBip44Path("m/44'/60'/0'/0/0")).toThrow(
+ InvalidBip44PathError,
+ )
+ })
+
+ test('rejects paths where the account segment is not hardened', () => {
+ expect(() => parseAlgorandBip44Path("m/44'/283'/0/0/0")).toThrow(
+ InvalidBip44PathError,
+ )
+ })
+
+ test('rejects paths with non-integer segments', () => {
+ expect(() => parseAlgorandBip44Path("m/44'/283'/foo'/0/0")).toThrow(
+ InvalidBip44PathError,
+ )
+ })
+
+ test('exposes the malformed reason on the thrown error', () => {
+ try {
+ parseAlgorandBip44Path('garbage')
+ } catch (error) {
+ expect(error).toBeInstanceOf(InvalidBip44PathError)
+ expect((error as InvalidBip44PathError).reason).toBe('malformed')
+ }
+ })
+})
+
+describe('hdPathMatchesDetails', () => {
+ test('returns true for a matching path', () => {
+ expect(hdPathMatchesDetails("m/44'/283'/0'/0/3", details)).toBe(true)
+ })
+
+ test('returns false for a mismatching keyIndex', () => {
+ expect(hdPathMatchesDetails("m/44'/283'/0'/0/99", details)).toBe(false)
+ })
+
+ test('propagates malformed-path errors', () => {
+ expect(() => hdPathMatchesDetails('bad', details)).toThrow(
+ InvalidBip44PathError,
+ )
+ })
+})
+
+describe('assertAlgorandBip44PathMatches', () => {
+ test('does not throw on match', () => {
+ expect(() =>
+ assertAlgorandBip44PathMatches("m/44'/283'/0'/0/3", details),
+ ).not.toThrow()
+ })
+
+ test('throws mismatch error when coordinates differ', () => {
+ try {
+ assertAlgorandBip44PathMatches("m/44'/283'/0'/0/99", details)
+ throw new Error('expected throw')
+ } catch (error) {
+ expect(error).toBeInstanceOf(InvalidBip44PathError)
+ expect((error as InvalidBip44PathError).reason).toBe('mismatch')
+ }
+ })
+
+ test('throws malformed error for invalid paths', () => {
+ try {
+ assertAlgorandBip44PathMatches('nope', details)
+ throw new Error('expected throw')
+ } catch (error) {
+ expect(error).toBeInstanceOf(InvalidBip44PathError)
+ expect((error as InvalidBip44PathError).reason).toBe('malformed')
+ }
+ })
+})
+
+describe('ALGORAND_COIN_TYPE', () => {
+ test('equals the SLIP-0044 constant', () => {
+ expect(ALGORAND_COIN_TYPE).toBe(283)
+ })
+})
diff --git a/packages/accounts/src/bip44.ts b/packages/accounts/src/bip44.ts
new file mode 100644
index 000000000..5e31ca935
--- /dev/null
+++ b/packages/accounts/src/bip44.ts
@@ -0,0 +1,170 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+import { AccountError } from './errors'
+import type { HDWalletDetails } from './models'
+
+/**
+ * Algorand's SLIP-0044 coin type. Used as the second segment of BIP44 paths
+ * for all Algorand HD wallet derivations.
+ */
+export const ALGORAND_COIN_TYPE = 283
+
+/**
+ * Structured form of an Algorand BIP44 path
+ * `m/44'/283'/'//`.
+ */
+export type ParsedAlgorandBip44Path = {
+ account: number
+ change: number
+ keyIndex: number
+}
+
+/**
+ * Why a BIP44 path failed validation.
+ *
+ * - `'malformed'`: the path string isn't a well-formed Algorand BIP44 path.
+ * - `'mismatch'`: the path parses cleanly but points to a different
+ * account/change/keyIndex than the HDWalletDetails being compared against.
+ */
+export type Bip44PathFailureReason = 'malformed' | 'mismatch'
+
+/**
+ * Thrown when {@link parseAlgorandBip44Path} or
+ * {@link assertAlgorandBip44PathMatches} reject a path. Carries a machine-
+ * readable `reason` so callers can map to domain-specific errors (e.g.
+ * ARC-60's `ERROR_FAILED_HD_PATH`) without re-parsing the message.
+ */
+export class InvalidBip44PathError extends AccountError {
+ readonly reason: Bip44PathFailureReason
+ readonly hdPath: string
+
+ constructor(
+ hdPath: string,
+ reason: Bip44PathFailureReason,
+ detail: string,
+ ) {
+ super(`Invalid BIP44 path "${hdPath}": ${detail}`, undefined, {
+ params: { hdPath, reason, detail },
+ })
+ this.reason = reason
+ this.hdPath = hdPath
+ }
+}
+
+/**
+ * Parses an Algorand BIP44 path `m/44'/283'/'//`.
+ *
+ * Accepts both `m/44'/...` and `44'/...` forms. The hardened marker may be
+ * either `'` (apostrophe) or `h`.
+ *
+ * Throws {@link InvalidBip44PathError} with `reason: 'malformed'` if the path
+ * is not a well-formed Algorand BIP44 path.
+ */
+export const parseAlgorandBip44Path = (
+ hdPath: string,
+): ParsedAlgorandBip44Path => {
+ const fail = (detail: string): never => {
+ throw new InvalidBip44PathError(hdPath, 'malformed', detail)
+ }
+
+ const segments = hdPath
+ .replace(/^m\//, '')
+ .split('/')
+ .map(s => s.trim())
+ .filter(Boolean)
+
+ if (segments.length !== 5) {
+ return fail('expected 5 path segments')
+ }
+
+ const parseHardened = (segment: string, label: string): number => {
+ if (!segment.endsWith("'") && !segment.endsWith('h')) {
+ return fail(`${label} must be hardened`)
+ }
+ const value = Number(segment.slice(0, -1))
+ if (!Number.isInteger(value) || value < 0) {
+ return fail(`${label} is not a valid integer`)
+ }
+ return value
+ }
+
+ const parseUnhardened = (segment: string, label: string): number => {
+ const value = Number(segment)
+ if (!Number.isInteger(value) || value < 0) {
+ return fail(`${label} is not a valid integer`)
+ }
+ return value
+ }
+
+ const purpose = parseHardened(segments[0], 'purpose')
+ const coin = parseHardened(segments[1], 'coin type')
+ const account = parseHardened(segments[2], 'account')
+ const change = parseUnhardened(segments[3], 'change')
+ const keyIndex = parseUnhardened(segments[4], 'keyIndex')
+
+ if (purpose !== 44) {
+ return fail(`purpose must be 44, got ${purpose}`)
+ }
+ if (coin !== ALGORAND_COIN_TYPE) {
+ return fail(`coin type must be ${ALGORAND_COIN_TYPE}, got ${coin}`)
+ }
+
+ return { account, change, keyIndex }
+}
+
+/**
+ * Returns `true` when `path` resolves to the same BIP44 coordinates as the
+ * given HD wallet details. Throws {@link InvalidBip44PathError} (with
+ * `reason: 'malformed'`) if the path is not parseable — malformed is a
+ * distinct failure mode from mismatch and callers typically want to surface
+ * different errors to the user.
+ */
+export const hdPathMatchesDetails = (
+ path: string,
+ details: HDWalletDetails,
+): boolean => {
+ const parsed = parseAlgorandBip44Path(path)
+ return (
+ parsed.account === details.account &&
+ parsed.change === details.change &&
+ parsed.keyIndex === details.keyIndex
+ )
+}
+
+/**
+ * Asserts that `path` resolves to the same BIP44 coordinates as the given
+ * HD wallet details. Throws {@link InvalidBip44PathError}:
+ *
+ * - `reason: 'malformed'` — path cannot be parsed
+ * - `reason: 'mismatch'` — path parses but targets a different derivation
+ *
+ * Callers that need a domain-specific error should catch and rewrap using
+ * the `reason` field.
+ */
+export const assertAlgorandBip44PathMatches = (
+ path: string,
+ details: HDWalletDetails,
+): void => {
+ const parsed = parseAlgorandBip44Path(path)
+ if (
+ parsed.account !== details.account ||
+ parsed.change !== details.change ||
+ parsed.keyIndex !== details.keyIndex
+ ) {
+ throw new InvalidBip44PathError(
+ path,
+ 'mismatch',
+ `does not match HD wallet (account=${details.account}, change=${details.change}, keyIndex=${details.keyIndex})`,
+ )
+ }
+}
diff --git a/packages/accounts/src/index.ts b/packages/accounts/src/index.ts
index da97f0cc3..fd7475bea 100644
--- a/packages/accounts/src/index.ts
+++ b/packages/accounts/src/index.ts
@@ -17,6 +17,7 @@ export * from './models'
export * from './hooks'
export * from './errors'
export * from './utils'
+export * from './bip44'
export * from './account-discovery'
export * from './db'
diff --git a/packages/signing/package.json b/packages/signing/package.json
index 5e4746055..fd9998034 100644
--- a/packages/signing/package.json
+++ b/packages/signing/package.json
@@ -18,6 +18,7 @@
},
"dependencies": {
"@babel/runtime": "catalog:",
+ "@noble/hashes": "^1.8.0",
"@perawallet/wallet-core-accounts": "workspace:*",
"@perawallet/wallet-core-hardware-wallet": "workspace:*",
"@perawallet/wallet-core-ledger": "workspace:*",
@@ -28,11 +29,13 @@
"@perawallet/wallet-core-shared": "workspace:*",
"@perawallet/wallet-extension-provider": "workspace:*",
"@tanstack/react-query": "catalog:",
+ "canonify": "^2.1.1",
"decimal.js": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"xstate": "catalog:",
"@xstate/react": "catalog:",
+ "zod": "catalog:",
"zustand": "catalog:"
},
"devDependencies": {
diff --git a/packages/signing/src/hooks/__tests__/useArbitraryDataSigner.spec.ts b/packages/signing/src/hooks/__tests__/useArbitraryDataSigner.spec.ts
index 5012b711e..d960380c7 100644
--- a/packages/signing/src/hooks/__tests__/useArbitraryDataSigner.spec.ts
+++ b/packages/signing/src/hooks/__tests__/useArbitraryDataSigner.spec.ts
@@ -76,7 +76,7 @@ describe('useArbitraryDataSigner', () => {
mockIsHDWalletAccount.mockReturnValue(true)
})
- test('calls session.signData with decoded bytes and no MX prefix', async () => {
+ test('calls session.signData with MX-prefixed bytes', async () => {
const mockSignData = vi
.fn()
.mockResolvedValue(new Uint8Array([9, 8, 7]))
@@ -100,9 +100,10 @@ describe('useArbitraryDataSigner', () => {
derivationType: 9,
})
- // MX prefix must NOT be present
- expect(dataArg[0]).not.toBe('M'.charCodeAt(0))
- expect(dataArg[1]).not.toBe('X'.charCodeAt(0))
+ // dApps verifying via the legacy algo_signData spec expect
+ // signatures over `MX || data`, so the wallet must prepend it.
+ expect(dataArg[0]).toBe('M'.charCodeAt(0))
+ expect(dataArg[1]).toBe('X'.charCodeAt(0))
})
test('calls withHDSession with SIGNING_KEY_DOMAIN', async () => {
diff --git a/packages/signing/src/hooks/__tests__/useArc60Signer.spec.ts b/packages/signing/src/hooks/__tests__/useArc60Signer.spec.ts
new file mode 100644
index 000000000..2fff316c3
--- /dev/null
+++ b/packages/signing/src/hooks/__tests__/useArc60Signer.spec.ts
@@ -0,0 +1,375 @@
+/*
+ Copyright 2022-2025 Pera Wallet, LDA
+ 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
+ */
+
+import { describe, test, expect, vi, beforeEach } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { sha256 } from '@noble/hashes/sha256'
+import { canonify } from 'canonify'
+import { encodeToBase64 } from '@perawallet/wallet-core-shared'
+import type { WalletAccount } from '@perawallet/wallet-core-accounts'
+import { useArc60Signer } from '../useArc60Signer'
+import {
+ ARC60_SCOPE_AUTH,
+ Arc60BadJsonError,
+ Arc60DomainMismatchError,
+ Arc60FailedHdPathError,
+ Arc60InvalidScopeError,
+ Arc60InvalidSignerError,
+} from '../../utils/arc60'
+import { SIWA_CHAIN_ID } from '../../utils/siwa'
+import type { Arc60Metadata, Arc60StdSigData } from '../../pipeline/types'
+
+const mockGetKeyOrThrow = vi.fn()
+const mockWithHDSession = vi.fn()
+const mockWithAlgo25Session = vi.fn()
+
+vi.mock('@perawallet/wallet-core-kms', () => ({
+ useKMS: () => ({
+ getKeyOrThrow: (...args: any[]) => mockGetKeyOrThrow(...args),
+ withHDSession: (...args: any[]) => mockWithHDSession(...args),
+ withAlgo25Session: (...args: any[]) => mockWithAlgo25Session(...args),
+ }),
+}))
+
+const mockIsHDWalletAccount = vi.fn()
+const mockIsAlgo25Account = vi.fn()
+const mockIsHardwareWalletAccount = vi.fn()
+let mockAccounts: WalletAccount[] = []
+
+vi.mock('@perawallet/wallet-core-accounts', async () => {
+ // Keep the real BIP44 helpers (pure, no store deps) so hdPath validation
+ // exercises the production code path from the accounts package.
+ const actual = await vi.importActual