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( + '@perawallet/wallet-core-accounts', + ) + return { + ...actual, + useAccountsStore: (selector: any) => + selector({ accounts: mockAccounts }), + isHDWalletAccount: (...args: any[]) => mockIsHDWalletAccount(...args), + isAlgo25Account: (...args: any[]) => mockIsAlgo25Account(...args), + isHardwareWalletAccount: (...args: any[]) => + mockIsHardwareWalletAccount(...args), + } +}) + +const mockKey = { id: 'key-1', type: 'HDWalletRootKey', publicKey: '' } + +const hdAccount = { + address: 'HD_ADDR', + keyPairId: 'key-1', + type: 'hdWallet', + hdWalletDetails: { + account: 0, + change: 0, + keyIndex: 1, + derivationType: 9, + }, +} as unknown as WalletAccount + +const algo25Account = { + address: 'ALGO25_ADDR', + keyPairId: 'key-1', + type: 'algo25', +} as unknown as WalletAccount + +const hardwareAccount = { + address: 'HW_ADDR', + type: 'hardware', +} as unknown as WalletAccount + +const domain = 'arc60.io' +const rpIdHash = sha256(new TextEncoder().encode(domain)) +const validAuthData = new Uint8Array([...rpIdHash, 0x05]) + +const buildSiwa = (overrides: Record = {}): string => + canonify({ + domain, + account_address: 'HD_ADDR', + uri: 'https://arc60.io/login', + version: '1', + chain_id: SIWA_CHAIN_ID, + type: 'ed25519', + ...overrides, + })! + +const samplePayload = new TextEncoder().encode(buildSiwa()) +const validStdSigData: Arc60StdSigData = { + data: encodeToBase64(samplePayload), + signer: 'HD_ADDR', + domain, + authenticatorData: validAuthData, +} +const validMetadata: Arc60Metadata = { + scope: ARC60_SCOPE_AUTH, + encoding: 'base64', +} + +describe('useArc60Signer', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAccounts = [] + mockGetKeyOrThrow.mockReturnValue(mockKey) + mockIsHDWalletAccount.mockReturnValue(false) + mockIsAlgo25Account.mockReturnValue(false) + mockIsHardwareWalletAccount.mockReturnValue(false) + }) + + test('rejects unsupported scope', async () => { + const { result } = renderHook(() => useArc60Signer()) + await expect( + act(async () => { + await result.current.signArc60(hdAccount, validStdSigData, { + scope: 99, + encoding: 'base64', + }) + }), + ).rejects.toBeInstanceOf(Arc60InvalidScopeError) + }) + + test('rejects hardware wallet accounts', async () => { + mockIsHardwareWalletAccount.mockReturnValue(true) + const { result } = renderHook(() => useArc60Signer()) + await expect( + act(async () => { + await result.current.signArc60( + hardwareAccount, + validStdSigData, + validMetadata, + ) + }), + ).rejects.toBeInstanceOf(Arc60InvalidSignerError) + }) + + test('rejects when authenticatorData rpIdHash mismatches', async () => { + mockIsHDWalletAccount.mockReturnValue(true) + const tampered = new Uint8Array(validAuthData) + tampered[0] ^= 0xff + const { result } = renderHook(() => useArc60Signer()) + await expect( + act(async () => { + await result.current.signArc60( + hdAccount, + { ...validStdSigData, authenticatorData: tampered }, + validMetadata, + ) + }), + ).rejects.toBeInstanceOf(Arc60DomainMismatchError) + }) + + test('signs with HD session using sha256(data)||sha256(authenticatorData) payload', async () => { + mockIsHDWalletAccount.mockReturnValue(true) + const sigBytes = new Uint8Array([1, 2, 3]) + const mockSignData = vi.fn().mockResolvedValue(sigBytes) + mockWithHDSession.mockImplementation( + async (_key: any, _domain: string, handler: any) => + handler({ signData: mockSignData }), + ) + + const { result } = renderHook(() => useArc60Signer()) + let signature: Uint8Array | undefined + await act(async () => { + signature = await result.current.signArc60( + hdAccount, + validStdSigData, + validMetadata, + ) + }) + + expect(signature).toEqual(sigBytes) + expect(mockSignData).toHaveBeenCalledTimes(1) + const [derivation, payload] = mockSignData.mock.calls[0] + expect(derivation).toEqual({ + account: 0, + keyIndex: 1, + derivationType: 9, + }) + // First 32 bytes = sha256(decoded data) + expect((payload as Uint8Array).slice(0, 32)).toEqual( + sha256(samplePayload), + ) + // Last 32 bytes = sha256(authenticatorData) + expect((payload as Uint8Array).slice(32)).toEqual(sha256(validAuthData)) + expect((payload as Uint8Array).length).toBe(64) + }) + + test('rejects when hdPath does not match the signer derivation', async () => { + mockIsHDWalletAccount.mockReturnValue(true) + const { result } = renderHook(() => useArc60Signer()) + await expect( + act(async () => { + await result.current.signArc60( + hdAccount, + { + ...validStdSigData, + // different keyIndex than the signer's hdWalletDetails + hdPath: "m/44'/283'/0'/0/99", + }, + validMetadata, + ) + }), + ).rejects.toBeInstanceOf(Arc60FailedHdPathError) + }) + + test('accepts a matching hdPath', async () => { + mockIsHDWalletAccount.mockReturnValue(true) + mockWithHDSession.mockImplementation( + async (_key: any, _domain: string, handler: any) => + handler({ + signData: vi.fn().mockResolvedValue(new Uint8Array([1])), + }), + ) + const { result } = renderHook(() => useArc60Signer()) + await expect( + act(async () => { + await result.current.signArc60( + hdAccount, + { ...validStdSigData, hdPath: "m/44'/283'/0'/0/1" }, + validMetadata, + ) + }), + ).resolves.not.toThrow() + }) + + test('rejects hdPath on Algo25 accounts', async () => { + mockIsAlgo25Account.mockReturnValue(true) + const algo25Siwa = new TextEncoder().encode( + buildSiwa({ account_address: 'ALGO25_ADDR' }), + ) + const { result } = renderHook(() => useArc60Signer()) + await expect( + act(async () => { + await result.current.signArc60( + algo25Account, + { + ...validStdSigData, + data: encodeToBase64(algo25Siwa), + signer: 'ALGO25_ADDR', + hdPath: "m/44'/283'/0'/0/0", + }, + validMetadata, + ) + }), + ).rejects.toBeInstanceOf(Arc60FailedHdPathError) + }) + + test('signs with Algo25 session and no MX prefix', async () => { + mockIsAlgo25Account.mockReturnValue(true) + const mockSignData = vi.fn().mockResolvedValue(new Uint8Array([7])) + mockWithAlgo25Session.mockImplementation( + async (_key: any, _domain: string, handler: any) => + handler({ signData: mockSignData }), + ) + const algo25Siwa = new TextEncoder().encode( + buildSiwa({ account_address: 'ALGO25_ADDR' }), + ) + + const { result } = renderHook(() => useArc60Signer()) + await act(async () => { + await result.current.signArc60( + algo25Account, + { + ...validStdSigData, + data: encodeToBase64(algo25Siwa), + signer: 'ALGO25_ADDR', + }, + validMetadata, + ) + }) + + const payload = mockSignData.mock.calls[0][0] as Uint8Array + // Must NOT start with "MX" + expect(payload[0]).not.toBe('M'.charCodeAt(0)) + expect(payload[1]).not.toBe('X'.charCodeAt(0)) + // Must start with sha256(decoded data) + expect(payload.slice(0, 32)).toEqual(sha256(algo25Siwa)) + }) + + test('rejects when SIWA domain does not match request domain', async () => { + mockIsHDWalletAccount.mockReturnValue(true) + const mismatched = new TextEncoder().encode( + buildSiwa({ domain: 'evil.io' }), + ) + const { result } = renderHook(() => useArc60Signer()) + await expect( + act(async () => { + await result.current.signArc60( + hdAccount, + { ...validStdSigData, data: encodeToBase64(mismatched) }, + validMetadata, + ) + }), + ).rejects.toBeInstanceOf(Arc60BadJsonError) + }) + + test('rejects when SIWA account_address does not match request signer', async () => { + mockIsHDWalletAccount.mockReturnValue(true) + const mismatched = new TextEncoder().encode( + buildSiwa({ account_address: 'OTHER_ADDR' }), + ) + const { result } = renderHook(() => useArc60Signer()) + await expect( + act(async () => { + await result.current.signArc60( + hdAccount, + { ...validStdSigData, data: encodeToBase64(mismatched) }, + validMetadata, + ) + }), + ).rejects.toBeInstanceOf(Arc60InvalidSignerError) + }) + + test('rejects when payload is not canonical SIWA JSON', async () => { + mockIsHDWalletAccount.mockReturnValue(true) + const nonSiwa = new TextEncoder().encode('{"not":"siwa"}') + const { result } = renderHook(() => useArc60Signer()) + await expect( + act(async () => { + await result.current.signArc60( + hdAccount, + { ...validStdSigData, data: encodeToBase64(nonSiwa) }, + validMetadata, + ) + }), + ).rejects.toBeInstanceOf(Arc60BadJsonError) + }) + + test('delegates to the rekeyed account when present', async () => { + const rekeyed = { + ...algo25Account, + address: 'REKEY_ADDR', + } as unknown as WalletAccount + const original = { + ...algo25Account, + address: 'ORIG_ADDR', + rekeyAddress: 'REKEY_ADDR', + } as unknown as WalletAccount + mockAccounts = [rekeyed] + mockIsAlgo25Account.mockReturnValue(true) + const mockSignData = vi.fn().mockResolvedValue(new Uint8Array([1])) + mockWithAlgo25Session.mockImplementation( + async (_key: any, _domain: string, handler: any) => + handler({ signData: mockSignData }), + ) + + const { result } = renderHook(() => useArc60Signer()) + await act(async () => { + await result.current.signArc60( + original, + validStdSigData, + validMetadata, + ) + }) + + expect(mockSignData).toHaveBeenCalled() + }) +}) diff --git a/packages/signing/src/hooks/__tests__/useSigningRequest.spec.ts b/packages/signing/src/hooks/__tests__/useSigningRequest.spec.ts index 3ed1020c4..36265f624 100644 --- a/packages/signing/src/hooks/__tests__/useSigningRequest.spec.ts +++ b/packages/signing/src/hooks/__tests__/useSigningRequest.spec.ts @@ -50,6 +50,18 @@ vi.mock('../useTransactionSigner', () => ({ })), })) +vi.mock('../useArbitraryDataSigner', () => ({ + useArbitraryDataSigner: vi.fn(() => ({ + signArbitraryData: vi.fn(), + })), +})) + +vi.mock('../useArc60Signer', () => ({ + useArc60Signer: vi.fn(() => ({ + signArc60: vi.fn(), + })), +})) + vi.mock('@perawallet/wallet-core-accounts', () => ({ useAllAccounts: vi.fn(() => [ { address: 'ADDR1', type: 'algo25' }, diff --git a/packages/signing/src/hooks/index.ts b/packages/signing/src/hooks/index.ts index 2e10c9af0..e6f35725f 100644 --- a/packages/signing/src/hooks/index.ts +++ b/packages/signing/src/hooks/index.ts @@ -10,6 +10,8 @@ limitations under the License */ +export * from './useArc60Signer' +export * from './useArbitraryDataSigner' export * from './useBalanceValidation' export * from './useSigningPipeline' export * from './useSigningRequest' diff --git a/packages/signing/src/hooks/useArbitraryDataSigner.ts b/packages/signing/src/hooks/useArbitraryDataSigner.ts index 37293c8ef..17f2432ac 100644 --- a/packages/signing/src/hooks/useArbitraryDataSigner.ts +++ b/packages/signing/src/hooks/useArbitraryDataSigner.ts @@ -45,6 +45,13 @@ export const useArbitraryDataSigner = () => { const signatures = await Promise.all( toSign.map(async item => { + // Match the legacy algo_signData spec: dApps verify + // the signature against `MX || data`. The Algo25 + // branch already does this; HD must mirror it. + const bytesToSign = concatBytes( + new TextEncoder().encode('MX'), + decodeFromBase64(item), + ) return session.signData( { account: hdWalletDetails.account, @@ -52,7 +59,7 @@ export const useArbitraryDataSigner = () => { derivationType: hdWalletDetails.derivationType, }, - decodeFromBase64(item), + bytesToSign, ) }), ) diff --git a/packages/signing/src/hooks/useArc60Signer.ts b/packages/signing/src/hooks/useArc60Signer.ts new file mode 100644 index 000000000..5a08f675e --- /dev/null +++ b/packages/signing/src/hooks/useArc60Signer.ts @@ -0,0 +1,210 @@ +/* + 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 } from 'react' +import { + assertAlgorandBip44PathMatches, + InvalidBip44PathError, + isAlgo25Account, + isHDWalletAccount, + isHardwareWalletAccount, + useAccountsStore, +} from '@perawallet/wallet-core-accounts' +import type { + Algo25Account, + HDWalletAccount, + WalletAccount, +} from '@perawallet/wallet-core-accounts' +import { useKMS } from '@perawallet/wallet-core-kms' +import { SIGNING_KEY_DOMAIN } from '../constants' +import type { Arc60Metadata, Arc60StdSigData } from '../pipeline/types' +import { + ARC60_SCOPE_AUTH, + Arc60BadJsonError, + Arc60FailedHdPathError, + Arc60InvalidScopeError, + Arc60InvalidSignerError, + buildArc60AuthSigningPayload, + decodeArc60Data, + verifyAuthenticatorDomain, +} from '../utils/arc60' +import { parseSiwa } from '../utils/siwa' + +export type UseArc60SignerResult = { + /** + * Produces a single ARC-60 AUTH-scope signature for the given signer + * account. Throws spec-aligned errors (`Arc60*Error`) for every rejection + * path so the caller can surface a precise reason to the dApp. + */ + signArc60: ( + account: WalletAccount, + stdSigData: Arc60StdSigData, + metadata: Arc60Metadata, + ) => Promise +} + +export const useArc60Signer = (): UseArc60SignerResult => { + const accounts = useAccountsStore(state => state.accounts) + const { getKeyOrThrow, withHDSession, withAlgo25Session } = useKMS() + + const signHd = useCallback( + async ( + account: HDWalletAccount, + payload: Uint8Array, + ): Promise => { + const key = getKeyOrThrow(account.keyPairId) + return withHDSession(key, SIGNING_KEY_DOMAIN, async session => + session.signData( + { + account: account.hdWalletDetails.account, + keyIndex: account.hdWalletDetails.keyIndex, + derivationType: account.hdWalletDetails.derivationType, + }, + payload, + ), + ) + }, + [getKeyOrThrow, withHDSession], + ) + + const signAlgo25 = useCallback( + async ( + account: Algo25Account, + payload: Uint8Array, + ): Promise => { + const key = getKeyOrThrow(account.keyPairId) + return withAlgo25Session(key, SIGNING_KEY_DOMAIN, async session => + // ARC-60 specifies the signing payload exactly — no MX prefix. + session.signData(payload), + ) + }, + [getKeyOrThrow, withAlgo25Session], + ) + + const signArc60 = useCallback( + async ( + account: WalletAccount, + stdSigData: Arc60StdSigData, + metadata: Arc60Metadata, + ): Promise => { + if (metadata.scope !== ARC60_SCOPE_AUTH) { + throw new Arc60InvalidScopeError(metadata.scope) + } + + if (isHardwareWalletAccount(account)) { + throw new Arc60InvalidSignerError( + account.address, + 'hardware wallet ARC-60 signing is not supported', + ) + } + + // Rekey: recurse into the auth account and validate hdPath against + // the *signing* account's actual derivation, not the original. + if (account.rekeyAddress) { + const rekeyedAccount = accounts.find( + a => a.address === account.rekeyAddress, + ) + if (!rekeyedAccount) { + throw new Arc60InvalidSignerError( + account.address, + `no rekeyed account found for ${account.rekeyAddress}`, + ) + } + return signArc60(rekeyedAccount, stdSigData, metadata) + } + + // Domain binding — verify before doing any signing work. + verifyAuthenticatorDomain( + stdSigData.domain, + stdSigData.authenticatorData, + ) + + const decodedData = decodeArc60Data( + stdSigData.data, + metadata.encoding, + ) + + // AUTH scope is strict SIWA: parse + canonicalize before signing + // so the dApp can't slip arbitrary bytes through the AUTH path. + let jsonString: string + try { + jsonString = new TextDecoder('utf-8', { fatal: true }).decode( + decodedData, + ) + } catch (caught) { + throw new Arc60BadJsonError( + 'decoded payload is not valid UTF-8', + caught instanceof Error ? caught : undefined, + ) + } + const siwa = parseSiwa(jsonString) + + if (siwa.domain !== stdSigData.domain) { + throw new Arc60BadJsonError( + `SIWA domain "${siwa.domain}" does not match request domain "${stdSigData.domain}"`, + ) + } + if (siwa.account_address !== stdSigData.signer) { + throw new Arc60InvalidSignerError( + stdSigData.signer, + `SIWA account_address "${siwa.account_address}" does not match request signer`, + ) + } + + const payload = buildArc60AuthSigningPayload( + decodedData, + stdSigData.authenticatorData, + ) + + if (isHDWalletAccount(account)) { + if (stdSigData.hdPath) { + try { + assertAlgorandBip44PathMatches( + stdSigData.hdPath, + account.hdWalletDetails, + ) + } catch (caught) { + if (caught instanceof InvalidBip44PathError) { + // Project the generic accounts-package error into + // the ARC-60 spec-aligned shape so the dApp gets + // `ERROR_FAILED_HD_PATH` semantics. + throw new Arc60FailedHdPathError( + caught.hdPath, + caught.message, + ) + } + throw caught + } + } + return signHd(account, payload) + } + + if (isAlgo25Account(account)) { + if (stdSigData.hdPath) { + throw new Arc60FailedHdPathError( + stdSigData.hdPath, + 'Algo25 accounts have no BIP44 derivation path', + ) + } + return signAlgo25(account, payload) + } + + throw new Arc60InvalidSignerError( + account.address, + `unsupported account type ${account.type}`, + ) + }, + [accounts, signAlgo25, signHd], + ) + + return { signArc60 } +} diff --git a/packages/signing/src/hooks/useSigningActorLifecycle.ts b/packages/signing/src/hooks/useSigningActorLifecycle.ts index f0d7df5ca..54e594df3 100644 --- a/packages/signing/src/hooks/useSigningActorLifecycle.ts +++ b/packages/signing/src/hooks/useSigningActorLifecycle.ts @@ -21,6 +21,8 @@ import { import { useAllAccounts } from '@perawallet/wallet-core-accounts' import { getProvider } from '@perawallet/wallet-extension-provider' import { useTransactionSigner } from './useTransactionSigner' +import { useArbitraryDataSigner } from './useArbitraryDataSigner' +import { useArc60Signer } from './useArc60Signer' import { useSigningStore } from '../store' import { createSigningMachine } from '../machine/createSigningMachine' import { signingMachine } from '../machine/signingMachine' @@ -79,6 +81,8 @@ export const useSigningActorLifecycle = (): UseSigningActorLifecycleResult => { ) const { signTransactions } = useTransactionSigner() + const { signArbitraryData } = useArbitraryDataSigner() + const { signArc60 } = useArc60Signer() const { encodeTransaction, encodeSignedTransactions } = useTransactionEncoder() const algokit = useAlgorandClient() @@ -97,6 +101,8 @@ export const useSigningActorLifecycle = (): UseSigningActorLifecycleResult => { const buildDeps = useCallback( (): SigningMachineDeps => ({ signTransactions, + signArbitraryData, + signArc60, createTransport: createTransportSelector({ algokit, encodeSignedTransactions, @@ -107,6 +113,8 @@ export const useSigningActorLifecycle = (): UseSigningActorLifecycleResult => { }), [ signTransactions, + signArbitraryData, + signArc60, encodeTransaction, encodeSignedTransactions, algokit, diff --git a/packages/signing/src/machine/__tests__/signingMachine.spec.ts b/packages/signing/src/machine/__tests__/signingMachine.spec.ts index 80370bf18..435bab67b 100644 --- a/packages/signing/src/machine/__tests__/signingMachine.spec.ts +++ b/packages/signing/src/machine/__tests__/signingMachine.spec.ts @@ -68,6 +68,8 @@ const mockTransportResult: TransportResult = { const mockDeps = { signTransactions: vi.fn(), + signArbitraryData: vi.fn(), + signArc60: vi.fn(), encodeSignedTransactions: vi .fn() .mockReturnValue([new Uint8Array([1, 2, 3])]), diff --git a/packages/signing/src/machine/actions.ts b/packages/signing/src/machine/actions.ts index 0abf58b1d..c1ddee4a8 100644 --- a/packages/signing/src/machine/actions.ts +++ b/packages/signing/src/machine/actions.ts @@ -151,6 +151,17 @@ const buildSourceMetadata = (request: SignRequest): SourceMetadata => { signer: result.signers[i]?.address ?? '', })), ) + } else if (result.signedData.type === 'arc60') { + // ARC-60 produces a single signature; project the result + // through the same `[{ signature, signer }]` shape so the + // callback consumer (WalletConnect bridge) doesn't need to + // branch on the modality. + await dataApprove([ + { + signature: result.signedData.signature, + signer: result.signers[0]?.address ?? '', + }, + ]) } } } @@ -240,7 +251,24 @@ const buildSignableGroups = ( ] } - throw new Error(`Unsupported request type: ${request.type}`) + if (isArc60Request(request)) { + return [ + { + data: { + type: 'arc60', + stdSigData: request.stdSigData, + metadata: request.metadata, + }, + source, + signerAddress: request.stdSigData.signer, + }, + ] + } + + const exhaustiveCheck: never = request + throw new Error( + `Unsupported request type: ${(exhaustiveCheck as { type: string }).type}`, + ) } // ============================================================================= @@ -255,6 +283,8 @@ const buildSignableGroups = ( */ const extractDeps = (input: SigningMachineInput): SigningMachineDeps => ({ signTransactions: input.signTransactions, + signArbitraryData: input.signArbitraryData, + signArc60: input.signArc60, createTransport: input.createTransport, network: input.network, encodeTransaction: input.encodeTransaction, diff --git a/packages/signing/src/machine/actors/signers/localKeySignerActor.ts b/packages/signing/src/machine/actors/signers/localKeySignerActor.ts index a2c81682e..df9daaad6 100644 --- a/packages/signing/src/machine/actors/signers/localKeySignerActor.ts +++ b/packages/signing/src/machine/actors/signers/localKeySignerActor.ts @@ -19,6 +19,8 @@ import type { import { createLocalKeyStrategy, type LocalSigningFunction, + type LocalArbitrarySigningFunction, + type LocalArc60SigningFunction, } from '../../../pipeline/signing/createLocalKeyStrategy' import { resolveAuthAccount } from '@perawallet/wallet-core-accounts' import { CannotSignError } from '../../../pipeline/errors' @@ -27,6 +29,8 @@ export type LocalKeySignerActorInput = { groups: AnalyzedSignableGroup[] allAccounts: WalletAccount[] signTransactions: LocalSigningFunction + signArbitraryData: LocalArbitrarySigningFunction + signArc60: LocalArc60SigningFunction } /** @@ -39,8 +43,18 @@ export const localKeySignerActor = fromPromise< SigningResult[], LocalKeySignerActorInput >(async ({ input }) => { - const { groups, allAccounts, signTransactions } = input - const strategy = createLocalKeyStrategy(signTransactions) + const { + groups, + allAccounts, + signTransactions, + signArbitraryData, + signArc60, + } = input + const strategy = createLocalKeyStrategy({ + signTransactions, + signArbitraryData, + signArc60, + }) return Promise.all( groups.map(group => { diff --git a/packages/signing/src/machine/context.ts b/packages/signing/src/machine/context.ts index 4370b9e22..b23bf56f7 100644 --- a/packages/signing/src/machine/context.ts +++ b/packages/signing/src/machine/context.ts @@ -22,7 +22,11 @@ import type { } from '../pipeline/types' import type { HardwareWalletRegistry } from '@perawallet/wallet-core-hardware-wallet' import type { SigningCallbacks } from '../pipeline/types' -import type { LocalSigningFunction } from '../pipeline/signing/createLocalKeyStrategy' +import type { + LocalSigningFunction, + LocalArbitrarySigningFunction, + LocalArc60SigningFunction, +} from '../pipeline/signing/createLocalKeyStrategy' import type { EncodeTransactionFunction } from '../pipeline/signing/createHardwareStrategy' import type { SignRequest } from '../models' @@ -68,6 +72,10 @@ export type TransportFactory = ( export type SigningMachineDeps = { /** KMS signing function from useTransactionSigner */ signTransactions: LocalSigningFunction + /** KMS arbitrary-data signing function from useArbitraryDataSigner */ + signArbitraryData: LocalArbitrarySigningFunction + /** KMS ARC-60 signing function from useArc60Signer */ + signArc60: LocalArc60SigningFunction /** Selects the correct transport (algod, callback, multisig, etc.) */ createTransport: TransportFactory /** Current network (mainnet / testnet) */ diff --git a/packages/signing/src/machine/signingMachine.ts b/packages/signing/src/machine/signingMachine.ts index 887e2ba34..baee57d48 100644 --- a/packages/signing/src/machine/signingMachine.ts +++ b/packages/signing/src/machine/signingMachine.ts @@ -305,6 +305,8 @@ export const signingMachine = setup({ ), allAccounts: context.allAccounts, signTransactions: context.deps.signTransactions, + signArbitraryData: context.deps.signArbitraryData, + signArc60: context.deps.signArc60, }), onDone: { target: 'dispatching', diff --git a/packages/signing/src/models/guards.ts b/packages/signing/src/models/guards.ts index c349c5707..525534072 100644 --- a/packages/signing/src/models/guards.ts +++ b/packages/signing/src/models/guards.ts @@ -30,4 +30,4 @@ export const isArbitraryDataRequest = ( export const isArc60Request = ( request: SignRequest, ): request is Arc60SignRequest => - request.type === 'arc60' && 'structuredData' in request + request.type === 'arc60' && 'stdSigData' in request diff --git a/packages/signing/src/models/index.ts b/packages/signing/src/models/index.ts index d868e19c8..17f03bfbc 100644 --- a/packages/signing/src/models/index.ts +++ b/packages/signing/src/models/index.ts @@ -16,7 +16,8 @@ import type { } from '@perawallet/wallet-core-blockchain' import { BaseStoreState } from '@perawallet/wallet-core-shared' import type { - Arc60Data, + Arc60Metadata, + Arc60StdSigData, SignableAnalysis, SourceType, TransportResult, @@ -91,8 +92,19 @@ export type ArbitraryDataSignRequest = { } & BaseSignRequest export type Arc60SignRequest = { - signer: string - structuredData: Arc60Data + /** + * Spec-defined signing payload. The encoded `data`, `domain`, + * `authenticatorData`, and optional `hdPath` are all consumed by the + * signing pipeline; `signer` selects the signing account. + */ + stdSigData: Arc60StdSigData + /** Scope + encoding metadata supplied by the dApp. */ + metadata: Arc60Metadata + /** + * Approve callback. Always invoked with a single-element array so the + * existing `algo_signData` response shape (array of base64 signatures) + * stays consistent across legacy and ARC-60 modalities. + */ approve?: (signed: PeraArbitraryDataSignResult[]) => Promise reject?: () => Promise error?: (error: Error) => Promise diff --git a/packages/signing/src/pipeline/signing/__tests__/createHardwareStrategy.spec.ts b/packages/signing/src/pipeline/signing/__tests__/createHardwareStrategy.spec.ts index a8ceb6268..f50d0d071 100644 --- a/packages/signing/src/pipeline/signing/__tests__/createHardwareStrategy.spec.ts +++ b/packages/signing/src/pipeline/signing/__tests__/createHardwareStrategy.spec.ts @@ -413,7 +413,7 @@ describe('createHardwareStrategy', () => { ).rejects.toThrow('signing failed') }) - it('throws HardwareWalletError for non-transaction data types', async () => { + it('throws SigningError for arbitrary-data (hardware not supported)', async () => { const strategy = createHardwareStrategy({ hardwareWalletRegistry: mockRegistry, encodeTransaction, @@ -425,7 +425,9 @@ describe('createHardwareStrategy', () => { await expect( strategy.sign(group, makeLedgerAccount()), - ).rejects.toThrow('unsupported_data_type') + ).rejects.toThrow( + 'Hardware wallet signing of arbitrary data is not supported', + ) }) }) }) diff --git a/packages/signing/src/pipeline/signing/createHardwareStrategy.ts b/packages/signing/src/pipeline/signing/createHardwareStrategy.ts index 66722d30e..da9b0081e 100644 --- a/packages/signing/src/pipeline/signing/createHardwareStrategy.ts +++ b/packages/signing/src/pipeline/signing/createHardwareStrategy.ts @@ -64,6 +64,18 @@ const validateAndExtract = ( ) } + if (group.data.type === 'arbitrary-data') { + throw new SigningError( + 'Hardware wallet signing of arbitrary data is not supported', + ) + } + + if (group.data.type === 'arc60') { + throw new SigningError( + 'Hardware wallet signing of ARC-60 requests is not supported', + ) + } + if (group.data.type !== 'transactions') { throw new HardwareWalletError('unsupported_data_type') } diff --git a/packages/signing/src/pipeline/signing/createLocalKeyStrategy.ts b/packages/signing/src/pipeline/signing/createLocalKeyStrategy.ts index 6cd5066f7..5184fd261 100644 --- a/packages/signing/src/pipeline/signing/createLocalKeyStrategy.ts +++ b/packages/signing/src/pipeline/signing/createLocalKeyStrategy.ts @@ -23,6 +23,8 @@ import type { SigningResult, SigningCallbacks, SignerInfo, + Arc60StdSigData, + Arc60Metadata, } from '../types' import { CannotSignError, SigningError } from '../errors' @@ -34,15 +36,38 @@ export type LocalSigningFunction = ( indexesToSign: number[], ) => Promise +/** + * Signing function type that matches useArbitraryDataSigner's signArbitraryData + */ +export type LocalArbitrarySigningFunction = ( + account: WalletAccount, + data: string | string[], +) => Promise + +/** + * Signing function type that matches useArc60Signer's signArc60 + */ +export type LocalArc60SigningFunction = ( + account: WalletAccount, + stdSigData: Arc60StdSigData, + metadata: Arc60Metadata, +) => Promise + +export type LocalKeyStrategyOptions = { + signTransactions: LocalSigningFunction + signArbitraryData: LocalArbitrarySigningFunction + signArc60: LocalArc60SigningFunction +} + /** * Creates a signing strategy for accounts with local keys (Algo25, HDWallet). * These accounts have immediate access to private keys via KMS. - * - * @param signTransactions - The signing function from useTransactionSigner */ export const createLocalKeyStrategy = ( - signTransactions: LocalSigningFunction, + options: LocalKeyStrategyOptions, ): SigningStrategy => { + const { signTransactions, signArbitraryData, signArc60 } = options + return { canSign: (account: WalletAccount): boolean => { return hasSigningKeys(account) @@ -53,7 +78,6 @@ export const createLocalKeyStrategy = ( account: WalletAccount, callbacks?: SigningCallbacks, ): Promise => { - // Validate that we can sign with this account if (!hasSigningKeys(account)) { throw new CannotSignError( account.address, @@ -68,51 +92,100 @@ export const createLocalKeyStrategy = ( ) } - // Only handle transaction signing - if (group.data.type !== 'transactions') { - throw new SigningError( - 'Local key strategy only supports transaction signing', - ) - } + switch (group.data.type) { + case 'transactions': { + const { transactions, indicesToSign } = group.data + try { + callbacks?.onSigningStart?.() + callbacks?.onProgress?.(0, transactions.length) - const { transactions, indicesToSign } = group.data + const signed = await signTransactions( + transactions, + indicesToSign, + ) - try { - callbacks?.onSigningStart?.() - callbacks?.onProgress?.(0, transactions.length) + callbacks?.onProgress?.( + transactions.length, + transactions.length, + ) + callbacks?.onSigningComplete?.() - // Sign the transactions - const signedTransactions = await signTransactions( - transactions, - indicesToSign, - ) + const signerInfo: SignerInfo = { + address: account.address, + } + return { + signedData: { type: 'transactions', signed }, + signers: [signerInfo], + originalIndices: group.originalIndices, + } + } catch (error) { + const signingError = new SigningError( + error instanceof Error + ? error.message + : String(error), + error instanceof Error ? error : undefined, + ) + callbacks?.onError?.(signingError) + throw signingError + } + } - callbacks?.onProgress?.( - transactions.length, - transactions.length, - ) - callbacks?.onSigningComplete?.() + case 'arbitrary-data': { + try { + callbacks?.onSigningStart?.() + const payloads = group.data.data.map(m => m.data) + const signatures = await signArbitraryData( + account, + payloads, + ) + callbacks?.onSigningComplete?.() - // Create signer info - const signerInfo: SignerInfo = { - address: account.address, + return { + signedData: { + type: 'arbitrary-data', + signatures, + }, + signers: [{ address: account.address }], + originalIndices: group.originalIndices, + } + } catch (error) { + const signingError = new SigningError( + error instanceof Error + ? error.message + : String(error), + error instanceof Error ? error : undefined, + ) + callbacks?.onError?.(signingError) + throw signingError + } } - return { - signedData: { - type: 'transactions', - signed: signedTransactions, - }, - signers: [signerInfo], - originalIndices: group.originalIndices, + case 'arc60': { + try { + callbacks?.onSigningStart?.() + const signature = await signArc60( + account, + group.data.stdSigData, + group.data.metadata, + ) + callbacks?.onSigningComplete?.() + + return { + signedData: { type: 'arc60', signature }, + signers: [{ address: account.address }], + originalIndices: group.originalIndices, + } + } catch (error) { + const signingError = new SigningError( + error instanceof Error + ? error.message + : String(error), + error instanceof Error ? error : undefined, + ) + callbacks?.onError?.(signingError) + throw signingError + } } - } catch (error) { - const signingError = new SigningError( - error instanceof Error ? error.message : String(error), - error instanceof Error ? error : undefined, - ) - callbacks?.onError?.(signingError) - throw signingError } }, } diff --git a/packages/signing/src/pipeline/signing/createMultisigStrategy.ts b/packages/signing/src/pipeline/signing/createMultisigStrategy.ts index 0529bc3c0..388446d28 100644 --- a/packages/signing/src/pipeline/signing/createMultisigStrategy.ts +++ b/packages/signing/src/pipeline/signing/createMultisigStrategy.ts @@ -13,7 +13,7 @@ import type { WalletAccount } from '@perawallet/wallet-core-accounts' import { isMultisigAccount } from '@perawallet/wallet-core-accounts' import type { SigningStrategy, SigningResult, SignerInfo } from '../types' -import { NoLocalParticipantsError } from '../errors' +import { NoLocalParticipantsError, SigningError } from '../errors' export interface CreateMultisigStrategyOptions { /** Get local participants for a multisig account */ @@ -48,6 +48,18 @@ export const createMultisigStrategy = ( }, sign: async (group, account, callbacks) => { + if (group.data.type === 'arbitrary-data') { + throw new SigningError( + 'Multisig signing of arbitrary data is not supported', + ) + } + + if (group.data.type === 'arc60') { + throw new SigningError( + 'Multisig signing of ARC-60 requests is not supported', + ) + } + const allAccounts = getAllAccounts() const localParticipants = getLocalParticipants(account, allAccounts) diff --git a/packages/signing/src/pipeline/signing/getSigningStrategy.ts b/packages/signing/src/pipeline/signing/getSigningStrategy.ts index 6294e42c0..7720e7318 100644 --- a/packages/signing/src/pipeline/signing/getSigningStrategy.ts +++ b/packages/signing/src/pipeline/signing/getSigningStrategy.ts @@ -23,6 +23,8 @@ import { CannotSignError } from '../errors' import { createLocalKeyStrategy, type LocalSigningFunction, + type LocalArbitrarySigningFunction, + type LocalArc60SigningFunction, } from './createLocalKeyStrategy' import { createHardwareStrategy, @@ -34,9 +36,15 @@ import { createMultisigStrategy } from './createMultisigStrategy' * Options for creating the signing strategy selector */ export interface GetSigningStrategyOptions { - /** The signing function from useTransactionSigner */ + /** Transaction signing function from useTransactionSigner */ signTransactions: LocalSigningFunction + /** Arbitrary-data signing function from useArbitraryDataSigner */ + signArbitraryData: LocalArbitrarySigningFunction + + /** ARC-60 signing function from useArc60Signer */ + signArc60: LocalArc60SigningFunction + /** Get local participants for a multisig account */ getLocalParticipants: ( account: WalletAccount, @@ -63,7 +71,11 @@ export const createSigningStrategySelector = ( account: WalletAccount, allAccounts: WalletAccount[], ) => SigningStrategy) => { - const localStrategy = createLocalKeyStrategy(options.signTransactions) + const localStrategy = createLocalKeyStrategy({ + signTransactions: options.signTransactions, + signArbitraryData: options.signArbitraryData, + signArc60: options.signArc60, + }) const hardwareStrategy = createHardwareStrategy({ hardwareWalletRegistry: options.hardwareWalletRegistry, encodeTransaction: options.encodeTransaction, diff --git a/packages/signing/src/pipeline/types.ts b/packages/signing/src/pipeline/types.ts index 2d9befade..550080327 100644 --- a/packages/signing/src/pipeline/types.ts +++ b/packages/signing/src/pipeline/types.ts @@ -43,21 +43,44 @@ export interface ArbitraryDataSignableData { } /** - * Arc60 structured data for signing + * ARC-60 structured signing request payload (StdSigData). + * + * Per ARC-60, `data` is opaque to the signing primitive (it is shown to the + * user after decoding for review only). The wallet must verify that + * `authenticatorData[0:32] === sha256(utf8(domain))` before signing. + */ +export interface Arc60StdSigData { + /** Encoded payload — decoded for display, hashed for signing. */ + data: string + /** Algorand address / Ed25519 public key of the signer. */ + signer: string + /** Origin requesting the signature (URL / DID / identifier). */ + domain: string + /** FIDO/WebAuthn authenticator data; first 32 bytes = sha256(domain). */ + authenticatorData: Uint8Array + /** Optional unique request id (echoed by the dApp for replay-protection). */ + requestId?: string + /** Optional BIP44 path the dApp expects the wallet to use. */ + hdPath?: string +} + +/** + * ARC-60 metadata accompanying a sign request. */ -export interface Arc60SignableData { - type: 'arc60' - structuredData: Arc60Data +export interface Arc60Metadata { + /** ARC-60 scope; only `1` (AUTH) is defined today. */ + scope: number + /** Encoding of `data` (e.g. 'base64'). */ + encoding: string } /** - * Arc60 structured data format + * ARC-60 signable data wrapper for the pipeline. */ -export interface Arc60Data { - domain: string - message: Record - primaryType: string - types: Record> +export interface Arc60SignableData { + type: 'arc60' + stdSigData: Arc60StdSigData + metadata: Arc60Metadata } /** diff --git a/packages/signing/src/utils/__tests__/arc60.spec.ts b/packages/signing/src/utils/__tests__/arc60.spec.ts new file mode 100644 index 000000000..3aacbd96c --- /dev/null +++ b/packages/signing/src/utils/__tests__/arc60.spec.ts @@ -0,0 +1,120 @@ +/* + 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 { sha256 } from '@noble/hashes/sha256' +import { encodeToBase64 } from '@perawallet/wallet-core-shared' +import { + Arc60DomainMismatchError, + Arc60FailedDecodingError, + Arc60MissingAuthDataError, + Arc60MissingDomainError, + buildArc60AuthSigningPayload, + decodeArc60Data, + verifyAuthenticatorDomain, +} from '../arc60' + +const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s) + +describe('decodeArc60Data', () => { + test('decodes base64-encoded payload', () => { + const payload = utf8('{"iss":"arc60.io"}') + const encoded = encodeToBase64(payload) + // Compare bytewise to sidestep TypedArray/Buffer prototype differences. + const decoded = decodeArc60Data(encoded, 'base64') + expect(Array.from(decoded)).toEqual(Array.from(payload)) + }) + + test('throws on unsupported encoding', () => { + expect(() => decodeArc60Data('deadbeef', 'hex')).toThrow( + Arc60FailedDecodingError, + ) + }) + + test('throws on malformed base64', () => { + // ! is not a valid base64 character; depending on the platform decoder + // this either yields garbage or throws — the helper normalises both + // outcomes into Arc60FailedDecodingError when the decode throws. + const decode = () => decodeArc60Data('not!valid!base64!@#', 'base64') + // Some base64 decoders are lenient. Accept either: a successful decode + // (lenient parser) OR Arc60FailedDecodingError. The important guarantee + // is that we never leak the underlying error type. + try { + decode() + } catch (error) { + expect(error).toBeInstanceOf(Arc60FailedDecodingError) + } + }) +}) + +describe('verifyAuthenticatorDomain', () => { + const domain = 'arc60.io' + const rpIdHash = sha256(utf8(domain)) + + test('passes when authenticatorData[0:32] matches sha256(domain)', () => { + const authData = new Uint8Array([...rpIdHash, 0x01, 0x02, 0x03]) + expect(() => verifyAuthenticatorDomain(domain, authData)).not.toThrow() + }) + + test('throws Arc60DomainMismatchError on hash mismatch', () => { + const tampered = new Uint8Array(rpIdHash) + tampered[0] ^= 0xff + const authData = new Uint8Array([...tampered, 0x00]) + expect(() => verifyAuthenticatorDomain(domain, authData)).toThrow( + Arc60DomainMismatchError, + ) + }) + + test('throws Arc60MissingDomainError when domain is empty', () => { + const authData = new Uint8Array([...rpIdHash]) + expect(() => verifyAuthenticatorDomain('', authData)).toThrow( + Arc60MissingDomainError, + ) + }) + + test('throws Arc60MissingAuthDataError when authenticatorData is too short', () => { + const tooShort = new Uint8Array(16) + expect(() => verifyAuthenticatorDomain(domain, tooShort)).toThrow( + Arc60MissingAuthDataError, + ) + }) +}) + +describe('buildArc60AuthSigningPayload', () => { + test('produces sha256(data) || sha256(authenticatorData) with the expected layout', () => { + const data = utf8('hello world') + const authData = new Uint8Array(32 + 4) + authData[32] = 0xaa + authData[33] = 0xbb + authData[34] = 0xcc + authData[35] = 0xdd + + const payload = buildArc60AuthSigningPayload(data, authData) + + const expectedDataHash = sha256(data) + const expectedAuthHash = sha256(authData) + // First 32 bytes = sha256(data) + expect(payload.slice(0, 32)).toEqual(expectedDataHash) + // Last 32 bytes = sha256(authenticatorData) + expect(payload.slice(32)).toEqual(expectedAuthHash) + expect(payload.length).toBe(64) + }) + + test('does not prepend the legacy MX prefix', () => { + const data = utf8('payload') + const authData = new Uint8Array(32) + const payload = buildArc60AuthSigningPayload(data, authData) + // The MX prefix would put 'M' = 0x4d at byte 0; the payload starts + // with sha256(data) instead. + expect(payload[0]).toBe(sha256(data)[0]) + }) +}) diff --git a/packages/signing/src/utils/__tests__/siwa.spec.ts b/packages/signing/src/utils/__tests__/siwa.spec.ts new file mode 100644 index 000000000..05fc38265 --- /dev/null +++ b/packages/signing/src/utils/__tests__/siwa.spec.ts @@ -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 { describe, expect, test } from 'vitest' +import { canonify } from 'canonify' +import { Arc60BadJsonError } from '../arc60' +import { parseSiwa, SIWA_CHAIN_ID } from '../siwa' + +const baseSiwa = { + domain: 'arc60.io', + account_address: 'ABC123', + uri: 'https://arc60.io/login', + version: '1', + chain_id: SIWA_CHAIN_ID, + type: 'ed25519', +} as const + +describe('parseSiwa', () => { + test('accepts a canonical minimal payload', () => { + const canonical = canonify(baseSiwa)! + const siwa = parseSiwa(canonical) + expect(siwa.domain).toBe('arc60.io') + expect(siwa.chain_id).toBe(SIWA_CHAIN_ID) + expect(siwa.type).toBe('ed25519') + }) + + test('accepts a canonical payload with optional fields', () => { + const full = { + ...baseSiwa, + statement: 'Sign in to Arc60', + nonce: 'abc123', + 'issued-at': '2026-01-01T00:00:00Z', + 'expiration-time': '2026-01-02T00:00:00Z', + 'not-before': '2026-01-01T00:00:00Z', + 'request-id': 'req-1', + resources: ['https://arc60.io/a', 'https://arc60.io/b'], + } + const canonical = canonify(full)! + const siwa = parseSiwa(canonical) + expect(siwa.resources).toEqual([ + 'https://arc60.io/a', + 'https://arc60.io/b', + ]) + }) + + test('rejects non-canonical key ordering', () => { + // canonify sorts keys alphabetically — reversing them yields a + // non-canonical string that must round-trip to an error. + const reversed = JSON.stringify( + baseSiwa, + Object.keys(baseSiwa).reverse(), + ) + expect(() => parseSiwa(reversed)).toThrow(Arc60BadJsonError) + }) + + test('rejects pretty-printed JSON (extra whitespace)', () => { + const pretty = JSON.stringify(baseSiwa, null, 2) + expect(() => parseSiwa(pretty)).toThrow(Arc60BadJsonError) + }) + + test('rejects malformed JSON', () => { + expect(() => parseSiwa('{not json')).toThrow(Arc60BadJsonError) + }) + + test('rejects missing required fields', () => { + const { domain: _, ...rest } = baseSiwa + const canonical = canonify(rest)! + expect(() => parseSiwa(canonical)).toThrow(Arc60BadJsonError) + }) + + test('rejects wrong chain_id', () => { + const canonical = canonify({ ...baseSiwa, chain_id: '416' })! + expect(() => parseSiwa(canonical)).toThrow(Arc60BadJsonError) + }) + + test('rejects wrong signature type', () => { + const canonical = canonify({ ...baseSiwa, type: 'secp256k1' })! + expect(() => parseSiwa(canonical)).toThrow(Arc60BadJsonError) + }) +}) diff --git a/packages/signing/src/utils/arc60.ts b/packages/signing/src/utils/arc60.ts new file mode 100644 index 000000000..eb5e4c7f3 --- /dev/null +++ b/packages/signing/src/utils/arc60.ts @@ -0,0 +1,222 @@ +/* + 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 { sha256 } from '@noble/hashes/sha256' +import { + AppError, + ErrorCategory, + ErrorSeverity, + concatBytes, + decodeFromBase64, +} from '@perawallet/wallet-core-shared' + +/** + * ARC-60 scope value for `AUTH` (the only scope defined by the spec today). + */ +export const ARC60_SCOPE_AUTH = 1 + +/** + * Encodings the wallet currently understands for ARC-60 `data`. + */ +export const ARC60_SUPPORTED_ENCODINGS = ['base64'] as const +export type Arc60SupportedEncoding = (typeof ARC60_SUPPORTED_ENCODINGS)[number] + +// ============================================================================= +// Errors +// ============================================================================= +// +// Aligned with the ARC-60 error catalogue so the WalletConnect bridge can +// surface spec-conformant rejection reasons. + +/** ERROR_INVALID_SCOPE — scope value not recognised by the wallet. */ +export class Arc60InvalidScopeError extends AppError { + constructor(scope: number) { + super(`ARC-60 scope ${scope} is not supported`, { + severity: ErrorSeverity.MEDIUM, + category: ErrorCategory.VALIDATION, + recoverable: false, + params: { scope }, + }) + } +} + +/** ERROR_FAILED_DECODING — `data` could not be decoded per `metadata.encoding`. */ +export class Arc60FailedDecodingError extends AppError { + constructor(encoding: string, originalError?: Error) { + super( + `Failed to decode ARC-60 data using encoding "${encoding}"`, + { + severity: ErrorSeverity.MEDIUM, + category: ErrorCategory.VALIDATION, + recoverable: false, + params: { encoding }, + }, + originalError, + ) + } +} + +/** ERROR_INVALID_SIGNER — signer not in the wallet (or not signable). */ +export class Arc60InvalidSignerError extends AppError { + constructor(signer: string, reason?: string) { + super( + reason + ? `ARC-60 signer ${signer} is invalid: ${reason}` + : `ARC-60 signer ${signer} is not available in this wallet`, + { + severity: ErrorSeverity.MEDIUM, + category: ErrorCategory.VALIDATION, + recoverable: false, + params: { signer, reason }, + }, + ) + } +} + +/** ERROR_MISSING_DOMAIN — `domain` field absent from request. */ +export class Arc60MissingDomainError extends AppError { + constructor() { + super('ARC-60 request is missing required `domain` field', { + severity: ErrorSeverity.MEDIUM, + category: ErrorCategory.VALIDATION, + recoverable: false, + }) + } +} + +/** ERROR_MISSING_AUTHENTICATED_DATA — `authenticatorData` absent from request. */ +export class Arc60MissingAuthDataError extends AppError { + constructor() { + super('ARC-60 request is missing required `authenticatorData` field', { + severity: ErrorSeverity.MEDIUM, + category: ErrorCategory.VALIDATION, + recoverable: false, + }) + } +} + +/** ERROR_BAD_JSON — AUTH-scope payload is not valid / canonical SIWA JSON. */ +export class Arc60BadJsonError extends AppError { + constructor(reason: string, originalError?: Error) { + super( + `ARC-60 AUTH payload is not a valid canonical SIWA JSON: ${reason}`, + { + severity: ErrorSeverity.MEDIUM, + category: ErrorCategory.VALIDATION, + recoverable: false, + params: { reason }, + }, + originalError, + ) + } +} + +/** ERROR_FAILED_DOMAIN_AUTH — `authenticatorData[0:32]` ≠ sha256(domain). */ +export class Arc60DomainMismatchError extends AppError { + constructor(domain: string) { + super( + `ARC-60 authenticatorData rpIdHash does not match sha256(${domain})`, + { + severity: ErrorSeverity.HIGH, + category: ErrorCategory.VALIDATION, + recoverable: false, + params: { domain }, + }, + ) + } +} + +/** ERROR_FAILED_HD_PATH — provided `hdPath` is invalid or doesn't match the signer. */ +export class Arc60FailedHdPathError extends AppError { + constructor(hdPath: string, reason?: string) { + super( + reason + ? `ARC-60 hdPath "${hdPath}" is invalid: ${reason}` + : `ARC-60 hdPath "${hdPath}" is invalid`, + { + severity: ErrorSeverity.MEDIUM, + category: ErrorCategory.VALIDATION, + recoverable: false, + params: { hdPath, reason }, + }, + ) + } +} + +// ============================================================================= +// Crypto / payload helpers +// ============================================================================= + +/** + * Decodes the ARC-60 `data` field per `metadata.encoding`. + * + * Only `base64` is supported in v1. Unknown encodings throw + * {@link Arc60FailedDecodingError}. + */ +export const decodeArc60Data = (data: string, encoding: string): Uint8Array => { + if (encoding !== 'base64') { + throw new Arc60FailedDecodingError(encoding) + } + try { + return decodeFromBase64(data) + } catch (error) { + throw new Arc60FailedDecodingError( + encoding, + error instanceof Error ? error : undefined, + ) + } +} + +/** + * Verifies the ARC-60 spec-required invariant + * `authenticatorData[0:32] === sha256(utf8(domain))`. + * + * Throws {@link Arc60DomainMismatchError} on mismatch and + * {@link Arc60MissingAuthDataError} when `authenticatorData` is too short. + */ +export const verifyAuthenticatorDomain = ( + domain: string, + authenticatorData: Uint8Array, +): void => { + if (!domain) { + throw new Arc60MissingDomainError() + } + if (!authenticatorData || authenticatorData.length < 32) { + throw new Arc60MissingAuthDataError() + } + const expected = sha256(new TextEncoder().encode(domain)) + // Constant-time compare over the 32-byte rpIdHash prefix. + let diff = 0 + for (let i = 0; i < 32; i++) { + diff |= expected[i] ^ authenticatorData[i] + } + if (diff !== 0) { + throw new Arc60DomainMismatchError(domain) + } +} + +/** + * Builds the ARC-60 AUTH-scope signing payload: + * + * ```text + * payload = sha256(decodedData) || sha256(authenticatorData) + * ``` + * + * 64 bytes total — two concatenated SHA-256 digests. Matches Lute's + * reference implementation so signatures are interop-compatible. The + * legacy Algorand `"MX"` arbitrary-data prefix is **not** prepended — + * domain separation is provided by `authenticatorData[0:32] == sha256(domain)`. + */ +export const buildArc60AuthSigningPayload = ( + decodedData: Uint8Array, + authenticatorData: Uint8Array, +): Uint8Array => concatBytes(sha256(decodedData), sha256(authenticatorData)) diff --git a/packages/signing/src/utils/index.ts b/packages/signing/src/utils/index.ts index d69460767..1717b6e71 100644 --- a/packages/signing/src/utils/index.ts +++ b/packages/signing/src/utils/index.ts @@ -10,9 +10,11 @@ limitations under the License */ +export * from './arc60' export * from './balance-validation' export * from './classification' export * from './fees' export * from './mergeSigningResults' export * from './resolveSignableTransactions' +export * from './siwa' export * from './warnings' diff --git a/packages/signing/src/utils/siwa.ts b/packages/signing/src/utils/siwa.ts new file mode 100644 index 000000000..a005784e6 --- /dev/null +++ b/packages/signing/src/utils/siwa.ts @@ -0,0 +1,80 @@ +/* + 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 { canonify } from 'canonify' +import { z } from 'zod' +import { Arc60BadJsonError } from './arc60' + +/** + * SIWx chain id for Algorand. Equals the SLIP-0044 coin type (283) and is + * constant across MainNet / TestNet (network is not part of the SIWA + * identifier — the domain + authenticatorData binding is). + */ +export const SIWA_CHAIN_ID = '283' + +/** + * Zod schema for a Sign-In With Algorand (SIWA) AUTH-scope payload. Field + * names match the Lute reference implementation so payloads interop across + * wallets. + */ +export const siwaSchema = z.object({ + domain: z.string().min(1), + account_address: z.string().min(1), + uri: z.string().min(1), + version: z.string().min(1), + statement: z.string().optional(), + nonce: z.string().optional(), + 'issued-at': z.string().optional(), + 'expiration-time': z.string().optional(), + 'not-before': z.string().optional(), + 'request-id': z.string().optional(), + chain_id: z.literal(SIWA_CHAIN_ID), + resources: z.array(z.string()).optional(), + type: z.literal('ed25519'), +}) + +export type Siwa = z.infer + +/** + * Parses and validates a SIWA AUTH-scope payload. + * + * Enforces both schema shape and canonical JSON form (RFC 8785). The raw + * JSON string the dApp sent MUST equal `canonify(parsed)` — otherwise two + * equivalent payloads could produce two different signatures, which would + * break signature-replay protections downstream. Mirrors Lute's behaviour. + */ +export const parseSiwa = (jsonString: string): Siwa => { + let parsed: unknown + try { + parsed = JSON.parse(jsonString) + } catch (error) { + throw new Arc60BadJsonError( + 'payload is not valid JSON', + error instanceof Error ? error : undefined, + ) + } + + const result = siwaSchema.safeParse(parsed) + if (!result.success) { + const summary = result.error.issues + .map(i => `${i.path.join('.') || '(root)'}: ${i.message}`) + .join('; ') + throw new Arc60BadJsonError(summary) + } + + const canonical = canonify(result.data) + if (!canonical || canonical !== jsonString) { + throw new Arc60BadJsonError('payload is not in canonical JSON form') + } + + return result.data +} diff --git a/packages/walletconnect/package.json b/packages/walletconnect/package.json index a93d4cdf5..ec4ff0b8d 100644 --- a/packages/walletconnect/package.json +++ b/packages/walletconnect/package.json @@ -27,6 +27,7 @@ "@walletconnect/types": "1.8.0", "@walletconnect/utils": "1.8.0", "react": "catalog:", + "zod": "catalog:", "zustand": "catalog:" }, "devDependencies": { diff --git a/packages/walletconnect/src/hooks/useWalletConnectHandlers.ts b/packages/walletconnect/src/hooks/useWalletConnectHandlers.ts index 3b6bd3ea5..d70a1d0e1 100644 --- a/packages/walletconnect/src/hooks/useWalletConnectHandlers.ts +++ b/packages/walletconnect/src/hooks/useWalletConnectHandlers.ts @@ -30,6 +30,9 @@ import { } from '@perawallet/wallet-core-blockchain' import { type ArbitraryDataSignRequest, + type Arc60Metadata, + type Arc60SignRequest, + type Arc60StdSigData, type PeraArbitraryDataMessage, type PeraArbitraryDataSignResult, type TransactionSignRequest, @@ -44,6 +47,7 @@ import { WalletConnectTransactionPayload, } from '../models' import { MAX_DATA_SIGN_REQUESTS } from '../constants' +import { arc60PayloadSchema } from '../schema' import { canSignWithAccount, isHardwareWalletAccount, @@ -97,8 +101,6 @@ const validateRequest = ( return foundConnection } -//TODO implement better error handling mechanism or maybe we just need to create a better -// Error boundary in the app? const validateDataSignRequest = ( connector: WalletConnect, accounts: WalletAccount[], @@ -157,6 +159,86 @@ const validateDataSignRequest = ( }) } +/** + * Validates an ARC-60 `algo_signData` payload before queueing it. + * + * Split into two layers: + * 1. Shape validation via {@link arc60PayloadSchema} — zod errors are + * projected into `WalletConnectSignRequestError` with a field path + * (e.g. `metadata.scope: Expected number, received string`). + * 2. Semantic checks — signer belongs to the session, is signable, and + * is not a hardware wallet. + * + * Returns the parsed `Arc60StdSigData` / `Arc60Metadata` with + * `authenticatorData` already base64-decoded so the caller doesn't repeat it. + */ +const validateArc60Request = ( + connector: WalletConnect, + accounts: WalletAccount[], + connections: WalletConnectConnection[], + network: Network, + rawParams: unknown, + error: Error | null, +): { stdSigData: Arc60StdSigData; metadata: Arc60Metadata } => { + const foundSession = validateRequest(connector, connections, network, error) + + const parsed = arc60PayloadSchema.safeParse(rawParams) + if (!parsed.success) { + // z.prettifyError would be nicer but is zod 4+; join issues manually + // so the dApp gets a field-path breadcrumb. + const summary = parsed.error.issues + .map(i => `${i.path.join('.') || '(root)'}: ${i.message}`) + .join('; ') + throw new WalletConnectSignRequestError( + `Invalid ARC-60 sign request payload — ${summary}`, + ) + } + const { + data, + signer, + domain, + authenticatorData, + requestId, + hdPath, + metadata, + } = parsed.data + + if (!foundSession.session?.accounts.includes(signer)) { + throw new WalletConnectInvalidSessionError('Invalid signer') + } + const account = accounts.find(a => a.address === signer) + if (!account || !canSignWithAccount(account, accounts)) { + throw new WalletConnectInvalidSessionError('Invalid signer') + } + if (isHardwareWalletAccount(account)) { + throw new WalletConnectInvalidSessionError( + 'Hardware wallet accounts are not supported', + ) + } + + let decodedAuthData: Uint8Array + try { + decodedAuthData = decodeFromBase64(authenticatorData) + } catch (decodeError) { + throw new WalletConnectSignRequestError( + 'Invalid ARC-60 sign request payload — `authenticatorData` is not valid base64', + decodeError as Error, + ) + } + + return { + stdSigData: { + data, + signer, + domain, + authenticatorData: decodedAuthData, + requestId, + hdPath, + }, + metadata, + } +} + export const useWalletConnectHandlers = () => { const connections = useWalletConnectStore( state => state.walletConnectConnections, @@ -167,7 +249,66 @@ export const useWalletConnectHandlers = () => { const accounts = useAllAccounts() const signingAccounts = useSigningAccounts() - //TODO handle ARC-60 sign requests + const handleArc60SignData = useCallback( + ( + connector: WalletConnect, + network: Network, + error: Error | null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload: any, + onError: (error: Error) => void, + ) => { + const { stdSigData, metadata } = validateArc60Request( + connector, + accounts, + connections, + network, + payload?.params, + error, + ) + + addSignRequest({ + id: generateOrderedUniqueId(), + type: 'arc60', + transport: 'callback', + sourceType: 'walletconnect', + transportId: connector.clientId, + sourceMetadata: connector.session?.peerMeta, + stdSigData, + metadata, + approve: async (signed: PeraArbitraryDataSignResult[]) => { + try { + // ARC-60 produces a single signature; the WC bridge + // mirrors the legacy `algo_signData` response shape + // (array of base64 strings) for consistency. + const result = signed.map(item => + encodeToBase64(item.signature), + ) + await connector.approveRequest({ + id: payload.id, + result, + }) + } catch (err) { + connector.rejectRequest({ + id: payload.id, + error: err as Error, + }) + } + }, + reject: async () => { + connector.rejectRequest({ + id: payload.id, + error: new Error('User rejected'), + }) + }, + error: async (err: Error) => { + onError(new WalletConnectSignRequestError(err.message)) + }, + } as Arc60SignRequest) + }, + [connections, accounts, addSignRequest], + ) + const handleSignData = useCallback( ( connector: WalletConnect, @@ -179,6 +320,23 @@ export const useWalletConnectHandlers = () => { onError: (error: Error) => void, ) => { const params = payload?.params + + // ARC-60 (`StdSigData` + `Metadata`) is delivered as a single + // object with an `authenticatorData` field, distinguishing it + // from the legacy arbitrary-data shape (an array of + // `PeraArbitraryDataMessage`). Detect on either signal so dApps + // that omit one don't slip through. + const isArc60Payload = + params != null && + !Array.isArray(params) && + (params.authenticatorData != null || + params.metadata?.scope != null) + + if (isArc60Payload) { + handleArc60SignData(connector, network, error, payload, onError) + return + } + validateDataSignRequest( connector, accounts, @@ -316,6 +474,7 @@ export const useWalletConnectHandlers = () => { return { handleSignData, + handleArc60SignData, handleSignTransaction, } } diff --git a/packages/walletconnect/src/schema.ts b/packages/walletconnect/src/schema.ts new file mode 100644 index 000000000..6d0c1a63e --- /dev/null +++ b/packages/walletconnect/src/schema.ts @@ -0,0 +1,34 @@ +/* + 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 { z } from 'zod' + +/** + * Zod schema for the wire shape of an ARC-60 `algo_signData` request. + * + * Mirrors ARC-60's `StdSigData` + `Metadata`. `data`, `signer`, `domain`, + * `authenticatorData` are required strings on the wire; `authenticatorData` + * is base64 and is decoded after parsing. Keep this as the sole source of + * truth for the wire shape — do not duplicate typeof checks elsewhere. + */ +export const arc60PayloadSchema = z.object({ + data: z.string(), + signer: z.string().min(1), + domain: z.string().min(1), + authenticatorData: z.string().min(1), + requestId: z.string().optional(), + hdPath: z.string().optional(), + metadata: z.object({ + scope: z.number().int(), + encoding: z.string().min(1), + }), +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeaa13681..0ab4e8ef7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,12 +9,42 @@ catalogs: '@algorandfoundation/algokit-utils': specifier: ^10.0.0-alpha.29 version: 10.0.0-alpha.40 + '@algorandfoundation/react-native-keystore': + specifier: 1.0.0-canary.5 + version: 1.0.0-canary.5 + '@algorandfoundation/wallet-provider': + specifier: 1.0.0-canary.3 + version: 1.0.0-canary.3 '@algorandfoundation/xhd-wallet-api': specifier: ^2.0.0-canary.1 version: 2.0.0-canary.1 '@babel/runtime': specifier: ^7.28.4 version: 7.28.6 + '@kubb/cli': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/core': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/plugin-client': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/plugin-msw': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/plugin-oas': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/plugin-react-query': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/plugin-ts': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/plugin-zod': + specifier: ^4.5.8 + version: 4.33.2 '@tanstack/query-async-storage-persister': specifier: ^5.90.9 version: 5.90.24 @@ -24,6 +54,9 @@ catalogs: '@tanstack/react-query-persist-client': specifier: ^5.90.9 version: 5.90.24 + '@tanstack/store': + specifier: ^0.8.1 + version: 0.8.1 '@testing-library/react': specifier: 16.3.0 version: 16.3.0 @@ -33,9 +66,30 @@ catalogs: '@vitejs/plugin-react': specifier: 5.1.0 version: 5.1.0 + '@vitest/coverage-v8': + specifier: ^4.0.16 + version: 4.0.18 + '@xstate/react': + specifier: ^6.1.0 + version: 6.1.0 + before-after-hook: + specifier: ^4.0.0 + version: 4.0.0 bip39: specifier: ^3.1.0 version: 3.1.0 + decimal.js: + specifier: ^10.6.0 + version: 10.6.0 + drizzle-orm: + specifier: ^0.44.0 + version: 0.44.7 + eslint: + specifier: 9.39.1 + version: 9.39.1 + eslint-plugin-unused-imports: + specifier: 4.3.0 + version: 4.3.0 oxfmt: specifier: ^0.41.0 version: 0.41.0 @@ -48,9 +102,18 @@ catalogs: util: specifier: ^0.12.5 version: 0.12.5 + uuid: + specifier: 13.0.0 + version: 13.0.0 + vite: + specifier: ^7.2.7 + version: 7.3.1 vitest: specifier: 4.0.16 version: 4.0.16 + xstate: + specifier: ^5.28.0 + version: 5.28.0 zod: specifier: ^4.1.12 version: 4.3.6 @@ -2057,6 +2120,9 @@ importers: '@babel/runtime': specifier: 'catalog:' version: 7.28.6 + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 '@perawallet/wallet-core-accounts': specifier: workspace:* version: link:../accounts @@ -2090,6 +2156,9 @@ importers: '@xstate/react': specifier: 'catalog:' version: 6.1.0(@types/react@19.2.14)(react@19.2.0)(xstate@5.28.0) + canonify: + specifier: ^2.1.1 + version: 2.1.1 decimal.js: specifier: 'catalog:' version: 10.6.0 @@ -2102,6 +2171,9 @@ importers: xstate: specifier: 'catalog:' version: 5.28.0 + zod: + specifier: 'catalog:' + version: 4.3.6 zustand: specifier: 'catalog:' version: 5.0.11(@types/react@19.2.14)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) @@ -2126,7 +2198,7 @@ importers: version: 4.5.4(@types/node@20.19.37)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.37)(jiti@2.5.1)(lightningcss@1.32.0)(terser@5.46.0)(yaml@2.8.2)) vitest: specifier: 'catalog:' - version: 4.0.16(@types/node@20.19.37)(jiti@2.5.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(terser@5.46.0)(yaml@2.8.2) + version: 4.0.16(@types/node@20.19.37)(jiti@2.5.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(terser@5.46.0)(yaml@2.8.2) packages/staking: dependencies: @@ -2340,6 +2412,9 @@ importers: react: specifier: 'catalog:' version: 19.2.0 + zod: + specifier: 'catalog:' + version: 4.3.6 zustand: specifier: 'catalog:' version: 5.0.11(@types/react@19.2.14)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) @@ -4129,89 +4204,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -4534,48 +4625,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.41.0': resolution: {integrity: sha512-VfVZxL0+6RU86T8F8vKiDBa+iHsr8PAjQmKGBzSCAX70b6x+UOMFl+2dNihmKmUwqkCazCPfYjt6SuAPOeQJ3g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.41.0': resolution: {integrity: sha512-bwzokz2eGvdfJbc0i+zXMJ4BBjQPqg13jyWpEEZDOrBCQ91r8KeY2Mi2kUeuMTZNFXju+jcAbAbpyJxRGla0eg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.41.0': resolution: {integrity: sha512-POLM//PCH9uqDeNDwWL3b3DkMmI3oI2cU6hwc2lnztD1o7dzrQs3R9nq555BZ6wI7t2lyhT9CS+CRaz5X0XqLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.41.0': resolution: {integrity: sha512-NNK7PzhFqLUwx/G12Xtm6scGv7UITvyGdAR5Y+TlqsG+essnuRWR4jRNODWRjzLZod0T3SayRbnkSIWMBov33w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.41.0': resolution: {integrity: sha512-qVf/zDC5cN9eKe4qI/O/m445er1IRl6swsSl7jHkqmOSVfknwCe5JXitYjZca+V/cNJSU/xPlC5EFMabMMFDpw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.41.0': resolution: {integrity: sha512-ojxYWu7vUb6ysYqVCPHuAPVZHAI40gfZ0PDtZAMwVmh2f0V8ExpPIKoAKr7/8sNbAXJBBpZhs2coypIo2jJX4w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.41.0': resolution: {integrity: sha512-O2exZLBxoCMIv2vlvcbkdedazJPTdG0VSup+0QUCfYQtx751zCZNboX2ZUOiQ/gDTdhtXvSiot0h6GEGkOyalA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.41.0': resolution: {integrity: sha512-N+31/VoL+z+NNBt8viy3I4NaIdPbiYeOnB884LKqvXldaE2dRztdPv3q5ipfZYv0RwFp7JfqS4I27K/DSHCakg==} @@ -4678,48 +4777,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.54.0': resolution: {integrity: sha512-WBNFe1foIFg6ipgzjzJfDLn7C5nZpLr/UHlnlT7GI3scCD2xpJiJTGMTTpJqA8p0Q1l2NsEnAOj9PtreF0Tkzg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.54.0': resolution: {integrity: sha512-M+UAW76rZHHiYKKy4e7Te5MNikANiFhYWl0qYF1MTIhQczYIqWVDQ+SX0SzW8ipOB/oK3+enOvlvJuhMoA968Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.54.0': resolution: {integrity: sha512-sGassljr8TR5F1WCqOaacrp3mYi107MnjkqlSXOMHACps7U2bL4v7M3RY7P7NMcGkNhzRHF3VO5+XyPWhTVM6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.54.0': resolution: {integrity: sha512-mW5Z6XTO8QWtlUjlf6yjntarjnbrmGVSK/V6XYy2rjH8xUfv9pn9G1vO92DBBBi0eUwnVpcOY3hMkP851WZNWg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.54.0': resolution: {integrity: sha512-nfPmEGW9BfWEUD0PVXGaYa2RS4wVblofNih1gsyUoLSWSyliNisIWd7F+QXrDSL5SJ78BPZjDW6FainV8qRiTg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.54.0': resolution: {integrity: sha512-gybxMQx4NN1T+pa8TLwgVS4u9H9Hxwm7Sl0qvnQJF2WRhNN1oTOCzkmmCBbW/29DYLeV99WU76zh7srCamK9yw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.54.0': resolution: {integrity: sha512-EAXMh2w3pzSj/aiB67kXzcHIoKxAywC1i1IgxA1sLY7iffEaZowDTOJgirY7WxeR/WiiKwak89gPeh9wUdLnIw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.54.0': resolution: {integrity: sha512-Ih7CfITkbw86+LjgV5gDwNmNQlvz7X+51gdeLLUDwuA/9lojDxCmRQEzeMl44KStQlwzCuNIcgGMae0MllV5ww==} @@ -5073,66 +5180,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -5915,6 +6035,9 @@ packages: caniuse-lite@1.0.30001777: resolution: {integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==} + canonify@2.1.1: + resolution: {integrity: sha512-bDHlvXhY1fmi9P6cF8PVvs0G7+zOGKYww+5OLBM30+TotDif8tvKFCpdIqP0MJT5vDAgdgvm6CToLnH3RRIhAA==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -7427,24 +7550,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -10911,6 +11038,11 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.0(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + optional: true + '@exodus/bytes@1.15.0(@noble/hashes@2.0.1)': optionalDependencies: '@noble/hashes': 2.0.1 @@ -13683,6 +13815,8 @@ snapshots: caniuse-lite@1.0.30001777: {} + canonify@2.1.1: {} + chai@6.2.2: {} chalk@2.4.2: @@ -14913,6 +15047,13 @@ snapshots: dependencies: lru-cache: 10.4.3 + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + optional: true + html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): dependencies: '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) @@ -15181,6 +15322,35 @@ snapshots: jsc-safe-url@0.2.4: {} + jsdom@27.4.0(@noble/hashes@1.8.0): + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) + cssstyle: 5.3.7 + data-urls: 6.0.1 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - bufferutil + - supports-color + - utf-8-validate + optional: true + jsdom@27.4.0(@noble/hashes@2.0.1): dependencies: '@acemir/cssom': 0.9.31 @@ -17448,6 +17618,44 @@ snapshots: terser: 5.46.0 yaml: 2.8.2 + vitest@4.0.16(@types/node@20.19.37)(jiti@2.5.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(terser@5.46.0)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.16 + '@vitest/mocker': 4.0.16(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(vite@7.3.1(@types/node@20.19.37)(jiti@2.5.1)(lightningcss@1.32.0)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.16 + '@vitest/runner': 4.0.16 + '@vitest/snapshot': 4.0.16 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@20.19.37)(jiti@2.5.1)(lightningcss@1.32.0)(terser@5.46.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.37 + jsdom: 27.4.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vitest@4.0.16(@types/node@20.19.37)(jiti@2.5.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(terser@5.46.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.16