Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions apps/mobile/src/__integration__/multisig-import-flow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/*
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 {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
} from 'vitest'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { View } from 'react-native'

import { server } from '@test-utils/msw-server'
import { renderWithNavigation } from '@test-utils/renderWithNavigation'
import { resetTestKeystore } from '@test-utils/algorand-keystore-test'
import { ImportSharedAccountScreen } from '@modules/multisig/screens/ImportSharedAccountScreen/ImportSharedAccountScreen'
import { NameMultisigScreen } from '@modules/multisig/screens/NameMultisigScreen/NameMultisigScreen'
import { useOnboardingStore } from '@modules/onboarding/hooks/useOnboardingStore'
import {
useAccountsStore,
type MultiSigAccount,
} from '@perawallet/wallet-core-accounts'
import { useDeviceStore } from '@perawallet/wallet-core-device'
import { generateMultisigAddress } from '@perawallet/wallet-core-blockchain'
import {
mockCreateMultisigAccount,
mockDeleteMultisigImportInbox,
mockGetMultisigAccountDetail,
} from '@perawallet/wallet-core-multisig/test-handlers'

import {
ALGO25_TEST_ADDRESS,
HD_TEST_ADDRESS,
REKEY_TARGET_ADDRESS,
} from './__fixtures__/onboarding'

// Deeplink parsing (perawallet://app/shared-account-import/?address=X and the
// joint-account-import alias) is unit-tested in
// src/hooks/deeplink/__tests__/new-parser.test.ts. This flow test starts at
// the screen the deeplink navigates to.

// `generateMultisigAddress` runs for real here and rejects non-base32 input,
// so the participants must be real Algorand addresses. The shared address is
// derived from them: `useNameMultisigScreen` re-derives and verifies it
// before persisting, so the backend response and the derivation must agree
// by construction.
const PARTICIPANTS = [
ALGO25_TEST_ADDRESS,
HD_TEST_ADDRESS,
REKEY_TARGET_ADDRESS,
]
const VERSION = 1
const THRESHOLD = 2
const SHARED_ADDRESS = generateMultisigAddress(VERSION, THRESHOLD, PARTICIPANTS)

const ACCOUNT_DETAIL_RESPONSE = {
custom_id: 'joint-1',
creation_datetime: '2024-01-01T00:00:00Z',
address: SHARED_ADDRESS,
version: VERSION,
threshold: THRESHOLD,
participant_addresses: PARTICIPANTS,
}

// Navigation transitions plus a `requestAnimationFrame` inside `handleFinish`
// push the wall-clock past the 5s default.
const SLOW_TEST_TIMEOUT_MS = 30000

describe('Flow: Import shared account by scanning its QR code', () => {
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

beforeEach(() => {
resetTestKeystore()
useAccountsStore.getState().setAccounts([])
useOnboardingStore.getState().reset()
// `handleFinish` bails out early without a device ID. Seed both
// networks so the test doesn't depend on the harness default.
useDeviceStore.getState().resetState()
useDeviceStore.getState().setDeviceID('mainnet', 'test-device-id')
useDeviceStore.getState().setDeviceID('testnet', 'test-device-id')
})

it(
'Given a scanned shared-account address, when the user reviews the preview and finishes naming, then a multisig account is persisted and selected',
async () => {
server.use(
mockGetMultisigAccountDetail({
address: SHARED_ADDRESS,
response: ACCOUNT_DETAIL_RESPONSE,
}),
mockCreateMultisigAccount({
response: ACCOUNT_DETAIL_RESPONSE,
}),
// "Add to Accounts" fires a fire-and-forget inbox-invitation
// delete, like Android — handle it so MSW stays quiet.
mockDeleteMultisigImportInbox({
deviceId: 'test-device-id',
multisigAddress: SHARED_ADDRESS,
}),
)

renderWithNavigation(
ImportSharedAccountScreen,
'ImportSharedAccount',
{
initialParams: { address: SHARED_ADDRESS },
additionalScreens: [
{ name: 'NameMultisig', component: NameMultisigScreen },
// exitAccountFlow resets to 'TabBar' after finishing
// — a stub gives the reset a real, observable target.
{
name: 'TabBar',
component: () => <View testID='import-flow-home' />,
},
],
},
)

// The preview renders once the backend lookup resolves: the
// threshold, every participant row, and both footer actions.
await waitFor(() =>
screen.getByTestId('import-shared-account-add-button'),
)
expect(
screen.getByTestId('import-shared-account-threshold'),
).toBeTruthy()
expect(
screen.getByTestId('import-shared-account-ignore-button'),
).toBeTruthy()
PARTICIPANTS.forEach(participant => {
expect(
screen.getByTestId(`import-participant-row-${participant}`),
).toBeTruthy()
})

// "Add to Accounts" hands off to the naming screen.
fireEvent.click(
screen.getByTestId('import-shared-account-add-button'),
)
await waitFor(() =>
screen.getByTestId('name_account_finish_button'),
)

// Name the account and finish.
fireEvent.change(screen.getByTestId('name_account_name_input'), {
target: { value: 'Team treasury' },
})
fireEvent.click(screen.getByTestId('name_account_finish_button'))

// The multisig account is persisted — with the verified address —
// and selected.
await waitFor(() => {
expect(useAccountsStore.getState().accounts).toHaveLength(1)
})
const saved = useAccountsStore.getState().accounts[0]
expect(saved.type).toBe('multisig')
expect(saved.address).toBe(SHARED_ADDRESS)
expect(saved.name).toBe('Team treasury')
expect((saved as MultiSigAccount).multisigDetails).toEqual({
threshold: THRESHOLD,
addresses: PARTICIPANTS,
})
expect(useAccountsStore.getState().selectedAccountAddress).toBe(
SHARED_ADDRESS,
)

// Finishing navigates away from the flow — the reset lands on
// the wallet home (the stub TabBar route).
await waitFor(() => screen.getByTestId('import-flow-home'))
},
SLOW_TEST_TIMEOUT_MS,
)
})
31 changes: 31 additions & 0 deletions apps/mobile/src/hooks/deeplink/__tests__/new-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,37 @@ describe('Deeplink Parser - New Format', () => {
})
})

describe('Shared account import', () => {
it('parses shared-account-import deeplink', () => {
const result = parseDeeplink(
`perawallet://app/shared-account-import/?address=${TEST_ADDRESS}`,
)
expect(result?.type).toBe(DeeplinkType.SHARED_ACCOUNT_IMPORT)
if (result?.type === DeeplinkType.SHARED_ACCOUNT_IMPORT) {
expect(result.address).toBe(TEST_ADDRESS)
}
})

it('parses the joint-account-import alias from a real QR payload', () => {
const address =
'GRW2GWUKSUGKMDMJR2SVDQ2OUX37AES4O4QB354UDIHIIDSN3FUB7BJDTA'
const result = parseDeeplink(
`perawallet://app/joint-account-import/?address=${address}`,
)
expect(result?.type).toBe(DeeplinkType.SHARED_ACCOUNT_IMPORT)
if (result?.type === DeeplinkType.SHARED_ACCOUNT_IMPORT) {
expect(result.address).toBe(address)
}
})

it('rejects shared-account-import without an address', () => {
const result = parseDeeplink(
'perawallet://app/joint-account-import/',
)
expect(result?.type).not.toBe(DeeplinkType.SHARED_ACCOUNT_IMPORT)
})
})

describe('Internal browser', () => {
it('parses internal-browser with base64 URL', () => {
const encodedUrl = btoa('https://perawallet.app/')
Expand Down
9 changes: 4 additions & 5 deletions apps/mobile/src/hooks/useDeepLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,11 +373,10 @@ export const useDeepLink = () => {
break

case DeeplinkType.SHARED_ACCOUNT_IMPORT:
// TODO(multisig PR 1): navigate to shared-account import flow
infoToast(
'Shared Account Import',
'Shared account import not implemented yet',
)
navigateToScreen(replaceCurrentScreen, 'Multisig', {
screen: 'ImportSharedAccount',
params: { address: parsedData.address },
})
break

case DeeplinkType.HOME:
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,23 @@
"duplicate_account_title": "Account already added",
"duplicate_account_body": "A shared account with these participants and threshold is already in your wallet."
},
"import": {
"title": "Shared Account",
"number_of_accounts": "Number of accounts",
"you_included": "You're a participant",
"threshold": "Threshold",
"threshold_description": "Minimum number of accounts \nrequired to confirm a transaction",
"accounts_heading": "Accounts ({{count}})",
"ignore": "Ignore",
"add_to_accounts": "Add to Accounts",
"already_imported": "This shared account is already in your wallet.",
"loading": "Loading shared account…",
"error_title": "Couldn't load shared account",
"error_body": "Please check your connection and try again.",
"retry": "Try Again",
"address_mismatch_title": "Couldn't verify shared account",
"address_mismatch_body": "This account's address doesn't match its participants and threshold. The shared account code may be invalid — ask the sender to share it again."
},
"edit_participant": {
"title": "Edit address",
"nickname_label": "Nickname",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ vi.mock('@react-navigation/native', async () => {
return {
...actual,
useNavigation: () => mockNavigation,
// Creation flow renders NameMultisig with no route params.
useRoute: () => ({ params: undefined }),
}
})

Expand Down Expand Up @@ -233,7 +235,7 @@ describe('multisig creation flow', () => {
expect(useMultisigCreationStore.getState().threshold).toBe(2)
})

it('blocks finish when account name collides with existing account', async () => {
it('allows finishing with a name already used by another account', async () => {
mockUseAllAccounts.mockReturnValue([
{ address: 'EXISTING', name: 'My Wallet' } as WalletAccount,
])
Expand All @@ -251,18 +253,11 @@ describe('multisig creation flow', () => {

const nameHook = renderHook(() => useNameMultisigScreen())

// Names are not required to be unique — a duplicate name still finishes.
act(() => {
nameHook.result.current.handleNameChange('My Wallet')
})

expect(nameHook.result.current.isNameTaken).toBe(true)
expect(nameHook.result.current.isFinishDisabled).toBe(true)

act(() => {
nameHook.result.current.handleNameChange('My Shared Wallet')
})

expect(nameHook.result.current.isNameTaken).toBe(false)
expect(nameHook.result.current.isFinishDisabled).toBe(false)
})

Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/modules/multisig/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { CreateMultisigScreen } from '../screens/CreateMultisigScreen'
import { EditParticipantScreen } from '../screens/EditParticipantScreen'
import { SetThresholdScreen } from '../screens/SetThresholdScreen'
import { NameMultisigScreen } from '../screens/NameMultisigScreen'
import { ImportSharedAccountScreen } from '../screens/ImportSharedAccountScreen'
import { MultisigStackParamList } from './types'

export type { MultisigStackParamList } from './types'
Expand Down Expand Up @@ -60,6 +61,11 @@ export const MultisigStackNavigator = () => {
options={{ title: '' }}
component={NameMultisigScreen}
/>
<MultisigStack.Screen
name='ImportSharedAccount'
options={{ title: '' }}
component={ImportSharedAccountScreen}
/>
</MultisigStack.Navigator>
)
}
16 changes: 15 additions & 1 deletion apps/mobile/src/modules/multisig/routes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,23 @@
limitations under the License
*/

/**
* Params passed to `NameMultisig` when naming an imported shared account
* (scanned via QR) rather than one built through the in-app creation flow.
* When absent, `NameMultisig` reads the participants/threshold from the
* multisig creation store instead.
*/
export type NameMultisigImportParams = {
address: string
threshold: number
addresses: string[]
version: number
}

export type MultisigStackParamList = {
CreateMultisig: undefined
EditParticipant: { address: string }
SetThreshold: undefined
NameMultisig: undefined
NameMultisig: NameMultisigImportParams | undefined
ImportSharedAccount: { address: string }
}
Loading
Loading