From 15cd72937605415c5b781324a93093fc5aadfce3 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:01:40 +0100 Subject: [PATCH 01/14] feat(signing): add useSignAndSubmitGroup pipeline helper [PERA-3966] --- .../__tests__/useSignAndSubmitGroup.spec.ts | 141 ++++++++++++++++++ packages/signing/src/hooks/index.ts | 1 + .../src/hooks/useSignAndSubmitGroup.ts | 121 +++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 packages/signing/src/hooks/__tests__/useSignAndSubmitGroup.spec.ts create mode 100644 packages/signing/src/hooks/useSignAndSubmitGroup.ts diff --git a/packages/signing/src/hooks/__tests__/useSignAndSubmitGroup.spec.ts b/packages/signing/src/hooks/__tests__/useSignAndSubmitGroup.spec.ts new file mode 100644 index 000000000..6a28b55a1 --- /dev/null +++ b/packages/signing/src/hooks/__tests__/useSignAndSubmitGroup.spec.ts @@ -0,0 +1,141 @@ +/* + 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, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { + useSignAndSubmitGroup, + UserRejectedSigningError, +} from '../useSignAndSubmitGroup' +import type { + PeraSignedTransaction, + PeraTransaction, +} from '@perawallet/wallet-core-blockchain' +import type { TransactionSignRequest } from '../../models' + +const mockAddSignRequest = vi.fn() +const mockSubmitSignedTransactionGroup = vi.fn() + +vi.mock('../useSigningRequest', () => ({ + useSigningRequest: () => ({ + addSignRequest: mockAddSignRequest, + }), +})) + +vi.mock('@perawallet/wallet-core-blockchain', () => ({ + useAlgorandClient: () => ({ client: { algod: {} } }), + useTransactionEncoder: () => ({ + encodeSignedTransactions: vi.fn(arr => arr.map(() => new Uint8Array([1]))), + }), +})) + +vi.mock('../../pipeline/submission/submitSignedTransactionGroup', () => ({ + submitSignedTransactionGroup: (...args: unknown[]) => + mockSubmitSignedTransactionGroup(...args), +})) + +const fakeTxn = { sender: { toString: () => 'A' } } as unknown as PeraTransaction + +describe('useSignAndSubmitGroup', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves with the algod txIds after the pipeline approves', async () => { + mockSubmitSignedTransactionGroup.mockResolvedValue(['tx1', 'tx2']) + let captured: TransactionSignRequest | undefined + mockAddSignRequest.mockImplementation((r: TransactionSignRequest) => { + captured = r + }) + + const { result } = renderHook(() => useSignAndSubmitGroup()) + + const promise = act(async () => + result.current.submit({ + unsignedTxs: [fakeTxn, fakeTxn], + source: { name: 'opt-in', description: 'test' }, + }), + ) + + // Drive the request: pretend the pipeline signed both txns. + const signed = [ + { txn: fakeTxn, sig: new Uint8Array([1]) }, + { txn: fakeTxn, sig: new Uint8Array([2]) }, + ] as PeraSignedTransaction[] + await captured?.approve?.(signed) + + await expect(promise).resolves.toEqual({ txIds: ['tx1', 'tx2'] }) + expect(captured?.headless).toBe(true) + expect(captured?.transport).toBe('callback') + expect(captured?.sourceType).toBe('local') + expect(captured?.txs).toEqual([fakeTxn, fakeTxn]) + expect(mockSubmitSignedTransactionGroup).toHaveBeenCalledTimes(1) + }) + + it('rejects with UserRejectedSigningError when reject() fires', async () => { + let captured: TransactionSignRequest | undefined + mockAddSignRequest.mockImplementation((r: TransactionSignRequest) => { + captured = r + }) + + const { result } = renderHook(() => useSignAndSubmitGroup()) + + const promise = act(async () => + result.current.submit({ + unsignedTxs: [fakeTxn], + source: { name: 'opt-out', description: 'test' }, + }), + ) + + await captured?.reject?.() + + await expect(promise).rejects.toBeInstanceOf(UserRejectedSigningError) + expect(mockSubmitSignedTransactionGroup).not.toHaveBeenCalled() + }) + + it('rejects with the original error when error() fires', async () => { + let captured: TransactionSignRequest | undefined + mockAddSignRequest.mockImplementation((r: TransactionSignRequest) => { + captured = r + }) + const upstream = new Error('LedgerDisconnected') + + const { result } = renderHook(() => useSignAndSubmitGroup()) + + const promise = act(async () => + result.current.submit({ + unsignedTxs: [fakeTxn], + source: { name: 'send', description: 'test' }, + }), + ) + + await captured?.error?.(upstream) + + await expect(promise).rejects.toBe(upstream) + expect(mockSubmitSignedTransactionGroup).not.toHaveBeenCalled() + }) + + it('returns immediately with no submission when given an empty group', async () => { + const { result } = renderHook(() => useSignAndSubmitGroup()) + + const res = await act(async () => + result.current.submit({ + unsignedTxs: [], + source: { name: 'no-op', description: 'test' }, + }), + ) + + expect(res).toEqual({ txIds: [] }) + expect(mockAddSignRequest).not.toHaveBeenCalled() + expect(mockSubmitSignedTransactionGroup).not.toHaveBeenCalled() + }) +}) diff --git a/packages/signing/src/hooks/index.ts b/packages/signing/src/hooks/index.ts index 4526776ff..9b8ac5a51 100644 --- a/packages/signing/src/hooks/index.ts +++ b/packages/signing/src/hooks/index.ts @@ -14,6 +14,7 @@ export * from './useArc60Signer' export * from './useArbitraryDataSigner' export * from './useBalanceValidation' export * from './useHardwareSigning' +export * from './useSignAndSubmitGroup' export * from './useSigningPipeline' export * from './useSigningRequest' export * from './useTransactionSigner' diff --git a/packages/signing/src/hooks/useSignAndSubmitGroup.ts b/packages/signing/src/hooks/useSignAndSubmitGroup.ts new file mode 100644 index 000000000..e5151f15f --- /dev/null +++ b/packages/signing/src/hooks/useSignAndSubmitGroup.ts @@ -0,0 +1,121 @@ +/* + 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 { + useAlgorandClient, + useTransactionEncoder, +} from '@perawallet/wallet-core-blockchain' +import type { + PeraSignedTransaction, + PeraTransaction, +} from '@perawallet/wallet-core-blockchain' +import { generateOrderedUniqueId } from '@perawallet/wallet-core-shared' +import { submitSignedTransactionGroup } from '../pipeline/submission/submitSignedTransactionGroup' +import type { TransactionSignRequest } from '../models' +import { useSigningRequest } from './useSigningRequest' + +/** + * Thrown when the user dismisses the LedgerSigningOverlay or the in-app + * signing sheet for a `headless: true` request. Callers should treat this + * as a non-fatal cancellation rather than a backend failure. + */ +export class UserRejectedSigningError extends Error { + constructor() { + super('User rejected signing') + this.name = 'UserRejectedSigningError' + } +} + +export type SignAndSubmitGroupSource = { + /** Short name shown in any sheet that renders pre-completion UI. */ + name: string + /** Human-readable description for the same UI surfaces. */ + description: string +} + +export type SignAndSubmitGroupParams = { + /** Unsigned transactions, already grouped by the caller. */ + unsignedTxs: PeraTransaction[] + /** Display metadata threaded through the pipeline. */ + source: SignAndSubmitGroupSource +} + +export type SignAndSubmitGroupResult = { + submit: ( + params: SignAndSubmitGroupParams, + ) => Promise<{ txIds: string[] }> +} + +/** + * Push a pre-built unsigned transaction group through the XState signing + * pipeline as a `headless: true`, `transport: 'callback'` request. Resolves + * with the algod txIds once the user approves and submission succeeds. + * + * Local-key accounts run validating → signing → completed without showing + * any sheet (headless skips the review state). Hardware-wallet accounts + * render the LedgerSigningOverlay automatically because the pipeline binds + * its phase callbacks for every actor. + */ +export const useSignAndSubmitGroup = (): SignAndSubmitGroupResult => { + const { addSignRequest } = useSigningRequest() + const algokit = useAlgorandClient() + const { encodeSignedTransactions } = useTransactionEncoder() + + const submit = useCallback( + ({ + unsignedTxs, + source, + }: SignAndSubmitGroupParams): Promise<{ txIds: string[] }> => { + if (unsignedTxs.length === 0) { + return Promise.resolve({ txIds: [] }) + } + return new Promise((resolve, reject) => { + const request: TransactionSignRequest = { + id: generateOrderedUniqueId(), + type: 'transactions', + transport: 'callback', + sourceType: 'local', + headless: true, + txs: unsignedTxs, + sourceMetadata: source, + approve: async (signed: PeraSignedTransaction[]) => { + try { + const txIds = await submitSignedTransactionGroup( + algokit, + encodeSignedTransactions, + signed, + ) + resolve({ txIds }) + } catch (err) { + reject( + err instanceof Error + ? err + : new Error(String(err)), + ) + } + }, + reject: async () => { + reject(new UserRejectedSigningError()) + }, + error: async (err: Error) => { + reject(err) + }, + } + addSignRequest(request) + }) + }, + [addSignRequest, algokit, encodeSignedTransactions], + ) + + return { submit } +} From ae2689f5c0d8c44ec3f95b66bd46a138410ddf01 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:08:17 +0100 Subject: [PATCH 02/14] feat(transactions): route asset opt-in through signing pipeline [PERA-3966] --- .../__tests__/useAssetOptInMutation.spec.ts | 181 ++++-------------- .../src/hooks/useAssetOptInMutation.ts | 34 ++-- 2 files changed, 52 insertions(+), 163 deletions(-) diff --git a/packages/transactions/src/hooks/__tests__/useAssetOptInMutation.spec.ts b/packages/transactions/src/hooks/__tests__/useAssetOptInMutation.spec.ts index e605d80c2..73d8ded7f 100644 --- a/packages/transactions/src/hooks/__tests__/useAssetOptInMutation.spec.ts +++ b/packages/transactions/src/hooks/__tests__/useAssetOptInMutation.spec.ts @@ -18,15 +18,17 @@ import { InsufficientBalanceForOptInError, } from '../useAssetOptInMutation' -const mockAssetOptIn = vi.fn() +const mockSubmit = vi.fn() const mockAccountInformation = vi.fn() const mockGetSuggestedParams = vi.fn() -const mockSignTransactions = vi.fn() +const mockBuild = vi.fn() +const mockNewGroup = vi.fn(() => ({ + addAssetOptIn: vi.fn().mockReturnThis(), + build: mockBuild, +})) vi.mock('@perawallet/wallet-core-signing', () => ({ - useTransactionSigner: () => ({ - signTransactions: mockSignTransactions, - }), + useSignAndSubmitGroup: () => ({ submit: mockSubmit }), })) vi.mock('@perawallet/wallet-core-accounts', () => ({ @@ -43,15 +45,11 @@ vi.mock('@perawallet/wallet-core-assets', () => ({ vi.mock('@perawallet/wallet-core-blockchain', () => ({ useNetwork: () => ({ network: 'testnet' }), useAlgorandClient: () => ({ - send: { - assetOptIn: mockAssetOptIn, - }, client: { - algod: { - accountInformation: mockAccountInformation, - }, + algod: { accountInformation: mockAccountInformation }, }, getSuggestedParams: mockGetSuggestedParams, + newGroup: mockNewGroup, }), ASSET_MBR: 100000n, })) @@ -65,10 +63,13 @@ describe('useAssetOptInMutation', () => { assets: [], }) mockGetSuggestedParams.mockResolvedValue({ minFee: 1000n }) - mockAssetOptIn.mockResolvedValue({ txIds: ['tx1'] }) + mockBuild.mockResolvedValue({ + transactions: [{ txn: { sender: 'SENDER' } }], + }) + mockSubmit.mockResolvedValue({ txIds: ['tx1'] }) }) - it('should opt in to an asset successfully', async () => { + it('builds an opt-in via composer and submits via the pipeline helper', async () => { const { result } = renderHook(() => useAssetOptInMutation()) await act(async () => { @@ -79,36 +80,35 @@ describe('useAssetOptInMutation', () => { expect(res.txIds).toEqual(['tx1']) }) - expect(mockAssetOptIn).toHaveBeenCalledWith({ - sender: 'SENDER', - assetId: 12345n, - }) + expect(mockNewGroup).toHaveBeenCalledTimes(1) + expect(mockSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + unsignedTxs: [{ sender: 'SENDER' }], + }), + ) }) - it('should reject when already opted in', async () => { - mockAccountInformation.mockResolvedValue({ + it('throws AlreadyOptedInError without calling the pipeline', async () => { + mockAccountInformation.mockResolvedValueOnce({ amount: 1000000n, minBalance: 100000n, - assets: [{ assetId: 12345n, amount: 0n }], + assets: [{ assetId: 12345n }], }) const { result } = renderHook(() => useAssetOptInMutation()) await act(async () => { await expect( - result.current.optIn({ - sender: 'SENDER', - assetId: 12345n, - }), - ).rejects.toThrow(AlreadyOptedInError) + result.current.optIn({ sender: 'SENDER', assetId: 12345n }), + ).rejects.toBeInstanceOf(AlreadyOptedInError) }) - expect(mockAssetOptIn).not.toHaveBeenCalled() + expect(mockSubmit).not.toHaveBeenCalled() }) - it('should reject when insufficient balance for MBR', async () => { - mockAccountInformation.mockResolvedValue({ - amount: 100000n, // Just enough for current MBR + it('throws InsufficientBalanceForOptInError without calling the pipeline', async () => { + mockAccountInformation.mockResolvedValueOnce({ + amount: 1n, minBalance: 100000n, assets: [], }) @@ -117,127 +117,10 @@ describe('useAssetOptInMutation', () => { await act(async () => { await expect( - result.current.optIn({ - sender: 'SENDER', - assetId: 12345n, - }), - ).rejects.toThrow(InsufficientBalanceForOptInError) - }) - - expect(mockAssetOptIn).not.toHaveBeenCalled() - }) - - it('should call insertAssetHolding and invalidateBalances on success', async () => { - const mockInsertAssetHolding = - await import('@perawallet/wallet-core-accounts').then( - m => m.insertAssetHolding, - ) - - const { result } = renderHook(() => useAssetOptInMutation()) - - await act(async () => { - await result.current.optIn({ - sender: 'SENDER', - assetId: 12345n, - }) - }) - - expect(mockInsertAssetHolding).toHaveBeenCalledWith({ - accountAddress: 'SENDER', - assetId: '12345', - network: 'testnet', - }) - }) - - it('should not call insertAssetHolding when transaction fails', async () => { - mockAssetOptIn.mockRejectedValue(new Error('signing rejected')) - const mockInsertAssetHolding = - await import('@perawallet/wallet-core-accounts').then( - m => m.insertAssetHolding, - ) - - const { result } = renderHook(() => useAssetOptInMutation()) - - await act(async () => { - await expect( - result.current.optIn({ - sender: 'SENDER', - assetId: 12345n, - }), - ).rejects.toThrow('signing rejected') - }) - - expect(mockInsertAssetHolding).not.toHaveBeenCalled() - }) - - it('should not call insertAssetHolding when validation fails', async () => { - mockAccountInformation.mockResolvedValue({ - amount: 1000000n, - minBalance: 100000n, - assets: [{ assetId: 12345n, amount: 0n }], - }) - const mockInsertAssetHolding = - await import('@perawallet/wallet-core-accounts').then( - m => m.insertAssetHolding, - ) - - const { result } = renderHook(() => useAssetOptInMutation()) - - await act(async () => { - await expect( - result.current.optIn({ - sender: 'SENDER', - assetId: 12345n, - }), - ).rejects.toThrow(AlreadyOptedInError) - }) - - expect(mockInsertAssetHolding).not.toHaveBeenCalled() - expect(mockAssetOptIn).not.toHaveBeenCalled() - }) - - it('should set error state on failure', async () => { - mockAccountInformation.mockRejectedValue(new Error('Network error')) - const { result } = renderHook(() => useAssetOptInMutation()) - - await act(async () => { - await expect( - result.current.optIn({ - sender: 'SENDER', - assetId: 12345n, - }), - ).rejects.toThrow('Network error') - }) - - expect(result.current.isError).toBe(true) - expect(result.current.error?.message).toBe('Network error') - }) - - it('should track loading state', async () => { - const { result } = renderHook(() => useAssetOptInMutation()) - - expect(result.current.isLoading).toBe(false) - - let resolveOptIn: (value: { txIds: string[] }) => void - mockAssetOptIn.mockReturnValue( - new Promise(resolve => { - resolveOptIn = resolve - }), - ) - - const optInPromise = act(async () => { - const promise = result.current.optIn({ - sender: 'SENDER', - assetId: 12345n, - }) - return promise - }) - - await act(async () => { - resolveOptIn!({ txIds: ['tx1'] }) + result.current.optIn({ sender: 'SENDER', assetId: 12345n }), + ).rejects.toBeInstanceOf(InsufficientBalanceForOptInError) }) - await optInPromise - expect(result.current.isLoading).toBe(false) + expect(mockSubmit).not.toHaveBeenCalled() }) }) diff --git a/packages/transactions/src/hooks/useAssetOptInMutation.ts b/packages/transactions/src/hooks/useAssetOptInMutation.ts index 651ee14a4..362ee4e1e 100644 --- a/packages/transactions/src/hooks/useAssetOptInMutation.ts +++ b/packages/transactions/src/hooks/useAssetOptInMutation.ts @@ -16,7 +16,7 @@ import { useAlgorandClient, useNetwork, } from '@perawallet/wallet-core-blockchain' -import { useTransactionSigner } from '@perawallet/wallet-core-signing' +import { useSignAndSubmitGroup } from '@perawallet/wallet-core-signing' import { insertAssetHolding, useAccountBalancesInvalidator, @@ -26,6 +26,8 @@ import { AlreadyOptedInError, InsufficientBalanceForOptInError, } from '../errors' + +export { AlreadyOptedInError, InsufficientBalanceForOptInError } import type { Nullable } from '@perawallet/wallet-core-shared' type AssetOptInParams = { @@ -40,9 +42,14 @@ type UseAssetOptInMutationResult = { error: Nullable } +const SOURCE = { + name: 'asset-opt-in', + description: 'Opt in to an asset', +} + export const useAssetOptInMutation = (): UseAssetOptInMutationResult => { - const { signTransactions } = useTransactionSigner() - const algokit = useAlgorandClient(signTransactions) + const algokit = useAlgorandClient() + const { submit } = useSignAndSubmitGroup() const { network } = useNetwork() const { invalidate: invalidateBalances } = useAccountBalancesInvalidator() const [isLoading, setIsLoading] = useState(false) @@ -55,7 +62,6 @@ export const useAssetOptInMutation = (): UseAssetOptInMutationResult => { setError(null) try { - // Check if already opted in const accountInfo = await algokit.client.algod.accountInformation(sender) const isOptedIn = accountInfo.assets?.some( @@ -65,7 +71,6 @@ export const useAssetOptInMutation = (): UseAssetOptInMutationResult => { throw new AlreadyOptedInError() } - // Check balance covers MBR increase + fee const suggestedParams = await algokit.getSuggestedParams() const balanceNeeded = accountInfo.minBalance + ASSET_MBR + suggestedParams.minFee @@ -73,15 +78,16 @@ export const useAssetOptInMutation = (): UseAssetOptInMutationResult => { throw new InsufficientBalanceForOptInError() } - const result = await algokit.send.assetOptIn({ - sender, - assetId, + const composer = algokit.newGroup() + composer.addAssetOptIn({ sender, assetId }) + const { transactions } = await composer.build() + const unsignedTxs = transactions.map(t => t.txn) + + const { txIds } = await submit({ + unsignedTxs, + source: SOURCE, }) - // Add the new holding to local DB and ensure the asset's - // metadata is persisted before invalidating, so the UI can - // resolve the asset on its next render instead of waiting - // for the next sync poll. const assetIdString = String(assetId) await insertAssetHolding({ accountAddress: sender, @@ -91,7 +97,7 @@ export const useAssetOptInMutation = (): UseAssetOptInMutationResult => { await fetchAndPersistAssets([assetIdString], network) invalidateBalances() - return { txIds: result.txIds } + return { txIds } } catch (err) { const error = err instanceof Error ? err : new Error(String(err)) @@ -101,7 +107,7 @@ export const useAssetOptInMutation = (): UseAssetOptInMutationResult => { setIsLoading(false) } }, - [algokit, network, invalidateBalances], + [algokit, submit, network, invalidateBalances], ) return { From f3761a8319f62cb4647ca82fa6c92edd29823bb1 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:17:24 +0100 Subject: [PATCH 03/14] fix(transactions): clean up asset opt-in re-export placement [PERA-3966] --- packages/transactions/src/hooks/useAssetOptInMutation.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/transactions/src/hooks/useAssetOptInMutation.ts b/packages/transactions/src/hooks/useAssetOptInMutation.ts index 362ee4e1e..8241f4293 100644 --- a/packages/transactions/src/hooks/useAssetOptInMutation.ts +++ b/packages/transactions/src/hooks/useAssetOptInMutation.ts @@ -26,8 +26,6 @@ import { AlreadyOptedInError, InsufficientBalanceForOptInError, } from '../errors' - -export { AlreadyOptedInError, InsufficientBalanceForOptInError } import type { Nullable } from '@perawallet/wallet-core-shared' type AssetOptInParams = { @@ -88,6 +86,10 @@ export const useAssetOptInMutation = (): UseAssetOptInMutationResult => { source: SOURCE, }) + // Add the new holding to local DB and ensure the asset's + // metadata is persisted before invalidating, so the UI can + // resolve the asset on its next render instead of waiting + // for the next sync poll. const assetIdString = String(assetId) await insertAssetHolding({ accountAddress: sender, @@ -118,4 +120,5 @@ export const useAssetOptInMutation = (): UseAssetOptInMutationResult => { } } +export { AlreadyOptedInError, InsufficientBalanceForOptInError } from '../errors' export type { AssetOptInParams, UseAssetOptInMutationResult } From 53e5d45ed439845c639d917939a0f8867aa5ee36 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:21:33 +0100 Subject: [PATCH 04/14] feat(transactions): route asset opt-out through signing pipeline [PERA-3966] --- .../__tests__/useAssetOptOutMutation.spec.ts | 231 +++++------------- .../src/hooks/useAssetOptOutMutation.ts | 50 ++-- 2 files changed, 84 insertions(+), 197 deletions(-) diff --git a/packages/transactions/src/hooks/__tests__/useAssetOptOutMutation.spec.ts b/packages/transactions/src/hooks/__tests__/useAssetOptOutMutation.spec.ts index adf28d8e6..8a20a6939 100644 --- a/packages/transactions/src/hooks/__tests__/useAssetOptOutMutation.spec.ts +++ b/packages/transactions/src/hooks/__tests__/useAssetOptOutMutation.spec.ts @@ -18,43 +18,26 @@ import { CreatorCannotOptOutError, } from '../useAssetOptOutMutation' -const mockAssetTransfer = vi.fn() -const mockAddAssetTransfer = vi.fn() -const mockComposerSend = vi.fn() +const mockSubmit = vi.fn() const mockAccountInformation = vi.fn() -const mockSignTransactions = vi.fn() +const mockBuild = vi.fn() +const mockAddAssetTransfer = vi.fn() +const mockNewGroup = vi.fn(() => ({ + addAssetTransfer: mockAddAssetTransfer.mockReturnThis(), + build: mockBuild, +})) const mockFetchIndexerAssetDetails = vi.fn() -const mockDeleteAssetHoldings = vi.fn() -const mockInvalidateBalances = vi.fn() vi.mock('@perawallet/wallet-core-signing', () => ({ - useTransactionSigner: () => ({ - signTransactions: mockSignTransactions, - }), + useSignAndSubmitGroup: () => ({ submit: mockSubmit }), })) vi.mock('@perawallet/wallet-core-blockchain', () => ({ - useAlgorandClient: () => { - const composer = { - addAssetTransfer: (...args: unknown[]) => { - mockAddAssetTransfer(...args) - return composer - }, - send: mockComposerSend, - } - return { - send: { - assetTransfer: mockAssetTransfer, - }, - client: { - algod: { - accountInformation: mockAccountInformation, - }, - }, - newGroup: () => composer, - } - }, - useNetwork: () => ({ network: 'mainnet' }), + useNetwork: () => ({ network: 'testnet' }), + useAlgorandClient: () => ({ + client: { algod: { accountInformation: mockAccountInformation } }, + newGroup: mockNewGroup, + }), })) vi.mock('@perawallet/wallet-core-assets', () => ({ @@ -63,203 +46,117 @@ vi.mock('@perawallet/wallet-core-assets', () => ({ })) vi.mock('@perawallet/wallet-core-accounts', () => ({ - deleteAssetHoldings: (...args: unknown[]) => - mockDeleteAssetHoldings(...args), - useAccountBalancesInvalidator: () => ({ - invalidate: mockInvalidateBalances, - }), + deleteAssetHoldings: vi.fn().mockResolvedValue(undefined), + useAccountBalancesInvalidator: () => ({ invalidate: vi.fn() }), })) +const baseAccount = { + amount: 1000000n, + minBalance: 100000n, + assets: [{ assetId: 12345n, amount: 0n }], +} + describe('useAssetOptOutMutation', () => { beforeEach(() => { vi.clearAllMocks() - mockAccountInformation.mockResolvedValue({ - assets: [ - { assetId: 100n, amount: 0n }, - { assetId: 200n, amount: 500n }, - ], + mockAccountInformation.mockResolvedValue(baseAccount) + mockBuild.mockResolvedValue({ + transactions: [{ txn: { sender: 'SENDER' } }], }) - mockAssetTransfer.mockResolvedValue({ txIds: ['tx1'] }) - mockComposerSend.mockResolvedValue({ txIds: ['tx1', 'tx2'] }) + mockSubmit.mockResolvedValue({ txIds: ['tx1'] }) mockFetchIndexerAssetDetails.mockResolvedValue({ - asset: { params: { creator: 'INDEXER_CREATOR' } }, + asset: { params: { creator: 'CREATOR' } }, }) - mockDeleteAssetHoldings.mockResolvedValue(undefined) }) - it('should opt out of a single asset with zero balance', async () => { + it('opts out of a single asset via the pipeline helper', async () => { const { result } = renderHook(() => useAssetOptOutMutation()) await act(async () => { const res = await result.current.optOut({ sender: 'SENDER', - assetId: 100n, + assetId: 12345n, creator: 'CREATOR', }) expect(res.txIds).toEqual(['tx1']) }) - expect(mockAssetTransfer).toHaveBeenCalledWith({ - sender: 'SENDER', - receiver: 'SENDER', - assetId: 100n, - amount: 0n, - closeAssetTo: 'CREATOR', - }) - }) - - it('should fetch creator from indexer when not provided', async () => { - const { result } = renderHook(() => useAssetOptOutMutation()) - - await act(async () => { - const res = await result.current.optOut({ + expect(mockAddAssetTransfer).toHaveBeenCalledWith( + expect.objectContaining({ sender: 'SENDER', - assetId: 100n, - }) - expect(res.txIds).toEqual(['tx1']) - }) - - expect(mockFetchIndexerAssetDetails).toHaveBeenCalledWith( - '100', - 'mainnet', + receiver: 'SENDER', + assetId: 12345n, + amount: 0n, + closeAssetTo: 'CREATOR', + }), ) - expect(mockAssetTransfer).toHaveBeenCalledWith({ - sender: 'SENDER', - receiver: 'SENDER', - assetId: 100n, - amount: 0n, - closeAssetTo: 'INDEXER_CREATOR', - }) + expect(mockSubmit).toHaveBeenCalledTimes(1) }) - it('should reject opt-out when balance is non-zero', async () => { - const { result } = renderHook(() => useAssetOptOutMutation()) - - await act(async () => { - await expect( - result.current.optOut({ - sender: 'SENDER', - assetId: 200n, - creator: 'CREATOR', - }), - ).rejects.toThrow(NonZeroBalanceError) + it('opts out of multiple assets in a single grouped pipeline request', async () => { + mockBuild.mockResolvedValueOnce({ + transactions: [ + { txn: { sender: 'SENDER' } }, + { txn: { sender: 'SENDER' } }, + ], }) - - expect(mockAssetTransfer).not.toHaveBeenCalled() - }) - - it('should reject opt-out when sender is creator', async () => { - const { result } = renderHook(() => useAssetOptOutMutation()) - - await act(async () => { - await expect( - result.current.optOut({ - sender: 'CREATOR', - assetId: 100n, - creator: 'CREATOR', - }), - ).rejects.toThrow(CreatorCannotOptOutError) + mockSubmit.mockResolvedValueOnce({ txIds: ['tx1', 'tx2'] }) + mockAccountInformation.mockResolvedValueOnce({ + ...baseAccount, + assets: [ + { assetId: 12345n, amount: 0n }, + { assetId: 67890n, amount: 0n }, + ], }) - expect(mockAssetTransfer).not.toHaveBeenCalled() - }) - - it('should build batch opt-out as atomic group', async () => { const { result } = renderHook(() => useAssetOptOutMutation()) await act(async () => { const res = await result.current.optOut([ - { sender: 'SENDER', assetId: 100n, creator: 'CREATOR_A' }, - { sender: 'SENDER', assetId: 100n, creator: 'CREATOR_B' }, + { sender: 'SENDER', assetId: 12345n, creator: 'C1' }, + { sender: 'SENDER', assetId: 67890n, creator: 'C2' }, ]) expect(res.txIds).toEqual(['tx1', 'tx2']) }) expect(mockAddAssetTransfer).toHaveBeenCalledTimes(2) - expect(mockComposerSend).toHaveBeenCalledTimes(1) - }) - - it('should return empty txIds for empty params array', async () => { - const { result } = renderHook(() => useAssetOptOutMutation()) - - await act(async () => { - const res = await result.current.optOut([]) - expect(res.txIds).toEqual([]) - }) - - expect(mockAccountInformation).not.toHaveBeenCalled() - expect(mockAssetTransfer).not.toHaveBeenCalled() - }) - - it('should fetch creator from indexer for batch when creators missing', async () => { - const { result } = renderHook(() => useAssetOptOutMutation()) - - await act(async () => { - await result.current.optOut([ - { sender: 'SENDER', assetId: 100n }, - { sender: 'SENDER', assetId: 100n, creator: 'KNOWN_CREATOR' }, - ]) - }) - - expect(mockFetchIndexerAssetDetails).toHaveBeenCalledTimes(1) - expect(mockFetchIndexerAssetDetails).toHaveBeenCalledWith( - '100', - 'mainnet', - ) + expect(mockSubmit).toHaveBeenCalledTimes(1) }) - it('should call deleteAssetHoldings and invalidateBalances on success', async () => { - const { result } = renderHook(() => useAssetOptOutMutation()) - - await act(async () => { - await result.current.optOut({ - sender: 'SENDER', - assetId: 100n, - creator: 'CREATOR', - }) + it('throws NonZeroBalanceError without calling the pipeline', async () => { + mockAccountInformation.mockResolvedValueOnce({ + ...baseAccount, + assets: [{ assetId: 12345n, amount: 5n }], }) - expect(mockDeleteAssetHoldings).toHaveBeenCalledWith({ - accountAddress: 'SENDER', - assetIds: ['100'], - network: 'mainnet', - }) - expect(mockInvalidateBalances).toHaveBeenCalled() - }) - - it('should not call deleteAssetHoldings when transaction fails', async () => { - mockAssetTransfer.mockRejectedValue(new Error('tx failed')) const { result } = renderHook(() => useAssetOptOutMutation()) await act(async () => { await expect( result.current.optOut({ sender: 'SENDER', - assetId: 100n, + assetId: 12345n, creator: 'CREATOR', }), - ).rejects.toThrow('tx failed') + ).rejects.toBeInstanceOf(NonZeroBalanceError) }) - expect(mockDeleteAssetHoldings).not.toHaveBeenCalled() - expect(mockInvalidateBalances).not.toHaveBeenCalled() + expect(mockSubmit).not.toHaveBeenCalled() }) - it('should set error state on failure', async () => { - mockAccountInformation.mockRejectedValue(new Error('Network error')) + it('throws CreatorCannotOptOutError when sender == creator', async () => { const { result } = renderHook(() => useAssetOptOutMutation()) await act(async () => { await expect( result.current.optOut({ - sender: 'SENDER', - assetId: 100n, + sender: 'CREATOR', + assetId: 12345n, creator: 'CREATOR', }), - ).rejects.toThrow('Network error') + ).rejects.toBeInstanceOf(CreatorCannotOptOutError) }) - expect(result.current.isError).toBe(true) - expect(result.current.error?.message).toBe('Network error') + expect(mockSubmit).not.toHaveBeenCalled() }) }) diff --git a/packages/transactions/src/hooks/useAssetOptOutMutation.ts b/packages/transactions/src/hooks/useAssetOptOutMutation.ts index 1ec1197c2..4c8abed83 100644 --- a/packages/transactions/src/hooks/useAssetOptOutMutation.ts +++ b/packages/transactions/src/hooks/useAssetOptOutMutation.ts @@ -15,7 +15,7 @@ import { useAlgorandClient, useNetwork, } from '@perawallet/wallet-core-blockchain' -import { useTransactionSigner } from '@perawallet/wallet-core-signing' +import { useSignAndSubmitGroup } from '@perawallet/wallet-core-signing' import { fetchIndexerAssetDetails } from '@perawallet/wallet-core-assets' import { deleteAssetHoldings, @@ -46,9 +46,14 @@ type ResolvedOptOutParams = { creator: string } +const SOURCE = { + name: 'asset-opt-out', + description: 'Opt out of an asset', +} + export const useAssetOptOutMutation = (): UseAssetOptOutMutationResult => { - const { signTransactions } = useTransactionSigner() - const algokit = useAlgorandClient(signTransactions) + const algokit = useAlgorandClient() + const { submit } = useSignAndSubmitGroup() const { network } = useNetwork() const { invalidate: invalidateBalances } = useAccountBalancesInvalidator() const [isLoading, setIsLoading] = useState(false) @@ -59,8 +64,6 @@ export const useAssetOptOutMutation = (): UseAssetOptOutMutationResult => { if (params.creator) { return params as ResolvedOptOutParams } - - // Fetch creator from indexer const assetDetails = await fetchIndexerAssetDetails( String(params.assetId), network, @@ -103,51 +106,37 @@ export const useAssetOptOutMutation = (): UseAssetOptOutMutationResult => { setError(null) try { - // Resolve any missing creator addresses const paramsList = await Promise.all( rawList.map(resolveCreator), ) - // All params in a batch must share the same sender const sender = paramsList[0].sender - // Fetch account info for validation const accountInfo = await algokit.client.algod.accountInformation(sender) const assets = accountInfo.assets ?? [] - // Validate all opt-outs before building transactions for (const p of paramsList) { validateOptOut(p, assets) } - // Build and send transaction group - let txIds: string[] - if (paramsList.length === 1) { - const p = paramsList[0] - const result = await algokit.send.assetTransfer({ + const composer = algokit.newGroup() + for (const p of paramsList) { + composer.addAssetTransfer({ sender: p.sender, receiver: p.sender, assetId: p.assetId, amount: 0n, closeAssetTo: p.creator, }) - txIds = result.txIds - } else { - // Batch: use composer for atomic group - const composer = algokit.newGroup() - for (const p of paramsList) { - composer.addAssetTransfer({ - sender: p.sender, - receiver: p.sender, - assetId: p.assetId, - amount: 0n, - closeAssetTo: p.creator, - }) - } - const result = await composer.send() - txIds = result.txIds } + const { transactions } = await composer.build() + const unsignedTxs = transactions.map(t => t.txn) + + const { txIds } = await submit({ + unsignedTxs, + source: SOURCE, + }) // Remove opted-out assets from local DB and refresh UI await deleteAssetHoldings({ @@ -167,7 +156,7 @@ export const useAssetOptOutMutation = (): UseAssetOptOutMutationResult => { setIsLoading(false) } }, - [algokit, resolveCreator, network, invalidateBalances], + [algokit, resolveCreator, submit, network, invalidateBalances], ) return { @@ -178,4 +167,5 @@ export const useAssetOptOutMutation = (): UseAssetOptOutMutationResult => { } } +export { NonZeroBalanceError, CreatorCannotOptOutError } from '../errors' export type { AssetOptOutParams, UseAssetOptOutMutationResult } From 7a31a9e2046b04e727d3a44a5a865c5328b07cfb Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:26:16 +0100 Subject: [PATCH 05/14] test(transactions): tighten asset opt-out post-submit assertions [PERA-3966] --- .../__tests__/useAssetOptOutMutation.spec.ts | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/transactions/src/hooks/__tests__/useAssetOptOutMutation.spec.ts b/packages/transactions/src/hooks/__tests__/useAssetOptOutMutation.spec.ts index 8a20a6939..f220afeea 100644 --- a/packages/transactions/src/hooks/__tests__/useAssetOptOutMutation.spec.ts +++ b/packages/transactions/src/hooks/__tests__/useAssetOptOutMutation.spec.ts @@ -27,6 +27,8 @@ const mockNewGroup = vi.fn(() => ({ build: mockBuild, })) const mockFetchIndexerAssetDetails = vi.fn() +const mockDeleteAssetHoldings = vi.fn().mockResolvedValue(undefined) +const mockInvalidate = vi.fn() vi.mock('@perawallet/wallet-core-signing', () => ({ useSignAndSubmitGroup: () => ({ submit: mockSubmit }), @@ -46,8 +48,8 @@ vi.mock('@perawallet/wallet-core-assets', () => ({ })) vi.mock('@perawallet/wallet-core-accounts', () => ({ - deleteAssetHoldings: vi.fn().mockResolvedValue(undefined), - useAccountBalancesInvalidator: () => ({ invalidate: vi.fn() }), + deleteAssetHoldings: (...args: unknown[]) => mockDeleteAssetHoldings(...args), + useAccountBalancesInvalidator: () => ({ invalidate: mockInvalidate }), })) const baseAccount = { @@ -90,7 +92,16 @@ describe('useAssetOptOutMutation', () => { closeAssetTo: 'CREATOR', }), ) - expect(mockSubmit).toHaveBeenCalledTimes(1) + expect(mockSubmit).toHaveBeenCalledWith({ + unsignedTxs: [{ sender: 'SENDER' }], + source: { name: 'asset-opt-out', description: 'Opt out of an asset' }, + }) + expect(mockDeleteAssetHoldings).toHaveBeenCalledWith({ + accountAddress: 'SENDER', + assetIds: ['12345'], + network: 'testnet', + }) + expect(mockInvalidate).toHaveBeenCalledTimes(1) }) it('opts out of multiple assets in a single grouped pipeline request', async () => { @@ -120,7 +131,16 @@ describe('useAssetOptOutMutation', () => { }) expect(mockAddAssetTransfer).toHaveBeenCalledTimes(2) - expect(mockSubmit).toHaveBeenCalledTimes(1) + expect(mockSubmit).toHaveBeenCalledWith({ + unsignedTxs: [{ sender: 'SENDER' }, { sender: 'SENDER' }], + source: { name: 'asset-opt-out', description: 'Opt out of an asset' }, + }) + expect(mockDeleteAssetHoldings).toHaveBeenCalledWith({ + accountAddress: 'SENDER', + assetIds: ['12345', '67890'], + network: 'testnet', + }) + expect(mockInvalidate).toHaveBeenCalledTimes(1) }) it('throws NonZeroBalanceError without calling the pipeline', async () => { @@ -159,4 +179,22 @@ describe('useAssetOptOutMutation', () => { expect(mockSubmit).not.toHaveBeenCalled() }) + + it('does not call deleteAssetHoldings when submit fails', async () => { + mockSubmit.mockRejectedValueOnce(new Error('user cancelled')) + const { result } = renderHook(() => useAssetOptOutMutation()) + + await act(async () => { + await expect( + result.current.optOut({ + sender: 'SENDER', + assetId: 12345n, + creator: 'CREATOR', + }), + ).rejects.toThrow('user cancelled') + }) + + expect(mockDeleteAssetHoldings).not.toHaveBeenCalled() + expect(mockInvalidate).not.toHaveBeenCalled() + }) }) From cea4ebc165c2d076e9c5bb722f4d13456abf7682 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:31:57 +0100 Subject: [PATCH 06/14] refactor(asa-inbox): ARC-59 hooks return unsigned tx groups [PERA-3966] --- .../useArc59ClaimTransaction.test.ts | 154 ++++++----- .../__tests__/useArc59Transaction.test.ts | 81 +++--- .../src/hooks/useArc59ClaimTransaction.ts | 245 +++++++++--------- .../src/hooks/useArc59SendTransaction.ts | 28 +- 4 files changed, 252 insertions(+), 256 deletions(-) diff --git a/packages/asa-inbox/src/hooks/__tests__/useArc59ClaimTransaction.test.ts b/packages/asa-inbox/src/hooks/__tests__/useArc59ClaimTransaction.test.ts index 392a04206..80a5aff62 100644 --- a/packages/asa-inbox/src/hooks/__tests__/useArc59ClaimTransaction.test.ts +++ b/packages/asa-inbox/src/hooks/__tests__/useArc59ClaimTransaction.test.ts @@ -60,7 +60,7 @@ vi.mock('../../clients', () => { } }) -const mockSigner = vi.fn().mockResolvedValue(['signed-tx']) +const STUB_TXN = { sender: 'SENDER_ADDRESS' } const MIN_FEE = 1000n @@ -74,7 +74,7 @@ describe('useArc59ClaimTransaction', () => { let mockComposer: { addAppCallMethodCall: Mock addAssetOptIn: Mock - send: Mock + build: Mock } let mockAccountInformation: Mock let mockAlgokit: { @@ -91,7 +91,9 @@ describe('useArc59ClaimTransaction', () => { mockComposer = { addAppCallMethodCall: vi.fn().mockReturnThis(), addAssetOptIn: vi.fn().mockReturnThis(), - send: vi.fn().mockResolvedValue({ txIds: ['tx1', 'tx2'] }), + build: vi + .fn() + .mockResolvedValue({ transactions: [{ txn: STUB_TXN }] }), } mockParamsClaimAlgo = vi @@ -113,35 +115,29 @@ describe('useArc59ClaimTransaction', () => { ;(useNetwork as Mock).mockReturnValue({ isMainnet: false }) }) - test('returns claimAsset and rejectAsset functions', () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + test('returns buildClaimAssetTxs and buildRejectAssetTxs functions', () => { + const { result } = renderHook(() => useArc59ClaimTransaction()) - expect(result.current.claimAsset).toBeTypeOf('function') - expect(result.current.rejectAsset).toBeTypeOf('function') + expect(result.current.buildClaimAssetTxs).toBeTypeOf('function') + expect(result.current.buildRejectAssetTxs).toBeTypeOf('function') }) - describe('claimAsset', () => { + describe('buildClaimAssetTxs', () => { test('does not add arc59_claimAlgo when shouldClaimAlgo is false', async () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.claimAsset(baseClaimParams) + await result.current.buildClaimAssetTxs(baseClaimParams) }) expect(mockParamsClaimAlgo).not.toHaveBeenCalled() }) test('prepends arc59_claimAlgo when shouldClaimAlgo is true', async () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.claimAsset({ + await result.current.buildClaimAssetTxs({ ...baseClaimParams, shouldClaimAlgo: true, }) @@ -152,12 +148,10 @@ describe('useArc59ClaimTransaction', () => { }) test('sets staticFee to 0 for arc59_claimAlgo (fee pooled to main call)', async () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.claimAsset({ + await result.current.buildClaimAssetTxs({ ...baseClaimParams, shouldClaimAlgo: true, }) @@ -171,12 +165,10 @@ describe('useArc59ClaimTransaction', () => { }) test('does not add asset opt-in when sender is already opted in', async () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.claimAsset(baseClaimParams) + await result.current.buildClaimAssetTxs(baseClaimParams) }) expect(mockComposer.addAssetOptIn).not.toHaveBeenCalled() @@ -185,12 +177,10 @@ describe('useArc59ClaimTransaction', () => { test('adds asset opt-in with staticFee 0 when sender is not opted in', async () => { mockAccountInformation.mockResolvedValue({ assets: [] }) - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.claimAsset(baseClaimParams) + await result.current.buildClaimAssetTxs(baseClaimParams) }) expect(mockComposer.addAssetOptIn).toHaveBeenCalledWith({ @@ -205,12 +195,10 @@ describe('useArc59ClaimTransaction', () => { new Error('account not found'), ) - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.claimAsset(baseClaimParams) + await result.current.buildClaimAssetTxs(baseClaimParams) }) expect(mockComposer.addAssetOptIn).toHaveBeenCalledWith({ @@ -221,12 +209,10 @@ describe('useArc59ClaimTransaction', () => { }) test('sets staticFee to 3 * minFee for arc59_claim (base case, opted in)', async () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.claimAsset(baseClaimParams) + await result.current.buildClaimAssetTxs(baseClaimParams) }) // Base fee: 3 * minFee (already opted in, no claimAlgo) @@ -238,12 +224,10 @@ describe('useArc59ClaimTransaction', () => { }) test('adds 2 * minFee to claim fee when shouldClaimAlgo is true', async () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.claimAsset({ + await result.current.buildClaimAssetTxs({ ...baseClaimParams, shouldClaimAlgo: true, }) @@ -260,12 +244,10 @@ describe('useArc59ClaimTransaction', () => { test('adds 1 * minFee to claim fee when not opted in', async () => { mockAccountInformation.mockResolvedValue({ assets: [] }) - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.claimAsset(baseClaimParams) + await result.current.buildClaimAssetTxs(baseClaimParams) }) // 3 * minFee (base) + 1 * minFee (opt-in) = 4 * minFee @@ -276,41 +258,47 @@ describe('useArc59ClaimTransaction', () => { ) }) - test('returns txIds from composed group', async () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + test('returns PeraTransaction[] from the built group', async () => { + const { result } = renderHook(() => useArc59ClaimTransaction()) + + let txResult: unknown + + await act(async () => { + txResult = + await result.current.buildClaimAssetTxs(baseClaimParams) + }) - let txResult: { txIds: string[] } | undefined + expect(Array.isArray(txResult)).toBe(true) + expect(txResult).toEqual([STUB_TXN]) + }) + + test('calls composer.build() (not send) after composing transactions', async () => { + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - txResult = await result.current.claimAsset(baseClaimParams) + await result.current.buildClaimAssetTxs(baseClaimParams) }) - expect(txResult).toEqual({ txIds: ['tx1', 'tx2'] }) + expect(mockComposer.build).toHaveBeenCalledTimes(1) }) }) - describe('rejectAsset', () => { + describe('buildRejectAssetTxs', () => { test('does not add arc59_claimAlgo when shouldClaimAlgo is false', async () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.rejectAsset(baseClaimParams) + await result.current.buildRejectAssetTxs(baseClaimParams) }) expect(mockParamsClaimAlgo).not.toHaveBeenCalled() }) test('prepends arc59_claimAlgo when shouldClaimAlgo is true', async () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.rejectAsset({ + await result.current.buildRejectAssetTxs({ ...baseClaimParams, shouldClaimAlgo: true, }) @@ -321,12 +309,10 @@ describe('useArc59ClaimTransaction', () => { }) test('sets staticFee to 3 * minFee for arc59_reject (base case)', async () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.rejectAsset(baseClaimParams) + await result.current.buildRejectAssetTxs(baseClaimParams) }) // Base fee: 3 * minFee (no claimAlgo) @@ -338,12 +324,10 @@ describe('useArc59ClaimTransaction', () => { }) test('sets staticFee to 0 for arc59_claimAlgo in reject flow', async () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.rejectAsset({ + await result.current.buildRejectAssetTxs({ ...baseClaimParams, shouldClaimAlgo: true, }) @@ -357,12 +341,10 @@ describe('useArc59ClaimTransaction', () => { }) test('adds 2 * minFee to reject fee when shouldClaimAlgo is true', async () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - await result.current.rejectAsset({ + await result.current.buildRejectAssetTxs({ ...baseClaimParams, shouldClaimAlgo: true, }) @@ -376,18 +358,28 @@ describe('useArc59ClaimTransaction', () => { ) }) - test('returns txIds from composed group', async () => { - const { result } = renderHook(() => - useArc59ClaimTransaction(mockSigner), - ) + test('returns PeraTransaction[] from the built group', async () => { + const { result } = renderHook(() => useArc59ClaimTransaction()) + + let txResult: unknown + + await act(async () => { + txResult = + await result.current.buildRejectAssetTxs(baseClaimParams) + }) + + expect(Array.isArray(txResult)).toBe(true) + expect(txResult).toEqual([STUB_TXN]) + }) - let txResult: { txIds: string[] } | undefined + test('calls composer.build() (not send) after composing transactions', async () => { + const { result } = renderHook(() => useArc59ClaimTransaction()) await act(async () => { - txResult = await result.current.rejectAsset(baseClaimParams) + await result.current.buildRejectAssetTxs(baseClaimParams) }) - expect(txResult).toEqual({ txIds: ['tx1', 'tx2'] }) + expect(mockComposer.build).toHaveBeenCalledTimes(1) }) }) }) diff --git a/packages/asa-inbox/src/hooks/__tests__/useArc59Transaction.test.ts b/packages/asa-inbox/src/hooks/__tests__/useArc59Transaction.test.ts index 0605fd1c5..598380c99 100644 --- a/packages/asa-inbox/src/hooks/__tests__/useArc59Transaction.test.ts +++ b/packages/asa-inbox/src/hooks/__tests__/useArc59Transaction.test.ts @@ -58,7 +58,7 @@ vi.mock('../../clients', () => { } }) -const mockSigner = vi.fn().mockResolvedValue(['signed-tx']) +const STUB_TXN = { sender: 'SENDER_ADDRESS' } const baseSummary = { is_arc59_opted_in: true, @@ -82,7 +82,7 @@ describe('useArc59SendTransaction', () => { let mockComposer: { addPayment: Mock addAppCallMethodCall: Mock - send: Mock + build: Mock } let mockAlgokit: { newGroup: Mock @@ -99,7 +99,9 @@ describe('useArc59SendTransaction', () => { mockComposer = { addPayment: vi.fn().mockReturnThis(), addAppCallMethodCall: vi.fn().mockReturnThis(), - send: vi.fn().mockResolvedValue({ txIds: ['tx1', 'tx2'] }), + build: vi + .fn() + .mockResolvedValue({ transactions: [{ txn: STUB_TXN }] }), } mockParamsOptRouterIn = vi @@ -120,19 +122,19 @@ describe('useArc59SendTransaction', () => { ;(useNetwork as Mock).mockReturnValue({ isMainnet: false }) }) - test('returns sendViaInbox function', () => { - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + test('returns buildSendViaInboxTxs function', () => { + const { result } = renderHook(() => useArc59SendTransaction()) - expect(result.current.sendViaInbox).toBeTypeOf('function') + expect(result.current.buildSendViaInboxTxs).toBeTypeOf('function') }) test('uses testnet config when not on mainnet', async () => { ;(useNetwork as Mock).mockReturnValue({ isMainnet: false }) - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + const { result } = renderHook(() => useArc59SendTransaction()) await act(async () => { - await result.current.sendViaInbox(baseParams) + await result.current.buildSendViaInboxTxs(baseParams) }) expect(arc59ClientConstructorArgs[0]).toEqual( @@ -145,10 +147,10 @@ describe('useArc59SendTransaction', () => { test('uses mainnet config when on mainnet', async () => { ;(useNetwork as Mock).mockReturnValue({ isMainnet: true }) - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + const { result } = renderHook(() => useArc59SendTransaction()) await act(async () => { - await result.current.sendViaInbox(baseParams) + await result.current.buildSendViaInboxTxs(baseParams) }) expect(arc59ClientConstructorArgs[0]).toEqual( @@ -164,10 +166,10 @@ describe('useArc59SendTransaction', () => { summary: { ...baseSummary, is_arc59_opted_in: false }, } - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + const { result } = renderHook(() => useArc59SendTransaction()) await act(async () => { - await result.current.sendViaInbox(params) + await result.current.buildSendViaInboxTxs(params) }) expect(mockParamsOptRouterIn).toHaveBeenCalledWith( @@ -182,10 +184,10 @@ describe('useArc59SendTransaction', () => { }) test('skips router opt-in when already opted in', async () => { - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + const { result } = renderHook(() => useArc59SendTransaction()) await act(async () => { - await result.current.sendViaInbox(baseParams) + await result.current.buildSendViaInboxTxs(baseParams) }) expect(mockParamsOptRouterIn).not.toHaveBeenCalled() @@ -201,10 +203,10 @@ describe('useArc59SendTransaction', () => { }, } - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + const { result } = renderHook(() => useArc59SendTransaction()) await act(async () => { - await result.current.sendViaInbox(params) + await result.current.buildSendViaInboxTxs(params) }) expect(mockComposer.addPayment).toHaveBeenCalledWith( @@ -217,10 +219,10 @@ describe('useArc59SendTransaction', () => { }) test('adds payment for MBR even when algo_fund_amount is 0', async () => { - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + const { result } = renderHook(() => useArc59SendTransaction()) await act(async () => { - await result.current.sendViaInbox(baseParams) + await result.current.buildSendViaInboxTxs(baseParams) }) // baseSummary has minimum_balance_requirement: 100000 @@ -241,20 +243,20 @@ describe('useArc59SendTransaction', () => { }, } - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + const { result } = renderHook(() => useArc59SendTransaction()) await act(async () => { - await result.current.sendViaInbox(params) + await result.current.buildSendViaInboxTxs(params) }) expect(mockComposer.addPayment).not.toHaveBeenCalled() }) test('adds arc59_sendAsset app call to the group', async () => { - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + const { result } = renderHook(() => useArc59SendTransaction()) await act(async () => { - await result.current.sendViaInbox(baseParams) + await result.current.buildSendViaInboxTxs(baseParams) }) expect(mockParamsSendAsset).toHaveBeenCalled() @@ -262,10 +264,10 @@ describe('useArc59SendTransaction', () => { }) test('creates asset transfer with correct params', async () => { - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + const { result } = renderHook(() => useArc59SendTransaction()) await act(async () => { - await result.current.sendViaInbox(baseParams) + await result.current.buildSendViaInboxTxs(baseParams) }) expect( @@ -280,23 +282,24 @@ describe('useArc59SendTransaction', () => { ) }) - test('returns txIds from the composed group send', async () => { - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + test('returns PeraTransaction[] from the built group', async () => { + const { result } = renderHook(() => useArc59SendTransaction()) - let txResult: { txIds: string[] } | undefined + let txResult: unknown await act(async () => { - txResult = await result.current.sendViaInbox(baseParams) + txResult = await result.current.buildSendViaInboxTxs(baseParams) }) - expect(txResult).toEqual({ txIds: ['tx1', 'tx2'] }) + expect(Array.isArray(txResult)).toBe(true) + expect(txResult).toEqual([STUB_TXN]) }) test('fetches suggested params before building transactions', async () => { - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + const { result } = renderHook(() => useArc59SendTransaction()) await act(async () => { - await result.current.sendViaInbox(baseParams) + await result.current.buildSendViaInboxTxs(baseParams) }) expect(mockAlgokit.getSuggestedParams).toHaveBeenCalledTimes(1) @@ -308,10 +311,10 @@ describe('useArc59SendTransaction', () => { summary: { ...baseSummary, is_arc59_opted_in: false }, } - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + const { result } = renderHook(() => useArc59SendTransaction()) await act(async () => { - await result.current.sendViaInbox(params) + await result.current.buildSendViaInboxTxs(params) }) expect(mockParamsOptRouterIn).toHaveBeenCalledWith( @@ -322,10 +325,10 @@ describe('useArc59SendTransaction', () => { }) test('uses suggestedParams.minFee * inner_tx_count for sendAsset extra fee', async () => { - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + const { result } = renderHook(() => useArc59SendTransaction()) await act(async () => { - await result.current.sendViaInbox(baseParams) + await result.current.buildSendViaInboxTxs(baseParams) }) const expectedFee = ( @@ -339,13 +342,13 @@ describe('useArc59SendTransaction', () => { ) }) - test('sends composer group after building', async () => { - const { result } = renderHook(() => useArc59SendTransaction(mockSigner)) + test('calls composer.build() (not send) after composing transactions', async () => { + const { result } = renderHook(() => useArc59SendTransaction()) await act(async () => { - await result.current.sendViaInbox(baseParams) + await result.current.buildSendViaInboxTxs(baseParams) }) - expect(mockComposer.send).toHaveBeenCalledTimes(1) + expect(mockComposer.build).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/asa-inbox/src/hooks/useArc59ClaimTransaction.ts b/packages/asa-inbox/src/hooks/useArc59ClaimTransaction.ts index 1be6d9829..ed7ff9164 100644 --- a/packages/asa-inbox/src/hooks/useArc59ClaimTransaction.ts +++ b/packages/asa-inbox/src/hooks/useArc59ClaimTransaction.ts @@ -11,12 +11,12 @@ */ import { useCallback } from 'react' -import { useNetwork } from '@perawallet/wallet-core-blockchain' -import { config } from '@perawallet/wallet-core-config' import { useAlgorandClient, - type PeraTransactionSigner, + useNetwork, } from '@perawallet/wallet-core-blockchain' +import type { PeraTransaction } from '@perawallet/wallet-core-blockchain' +import { config } from '@perawallet/wallet-core-config' import { ARC59Client } from '../clients' import { BASE_CLAIM_TX_COUNT, @@ -37,132 +37,133 @@ type RejectParams = { } type UseArc59ClaimTransactionResult = { - claimAsset: (params: ClaimParams) => Promise<{ txIds: string[] }> - rejectAsset: (params: RejectParams) => Promise<{ txIds: string[] }> + buildClaimAssetTxs: (params: ClaimParams) => Promise + buildRejectAssetTxs: (params: RejectParams) => Promise } -export const useArc59ClaimTransaction = ( - signer: PeraTransactionSigner, -): UseArc59ClaimTransactionResult => { - const { isMainnet } = useNetwork() - const algokit = useAlgorandClient(signer) - - const isOptedInToAsset = useCallback( - async (address: string, assetId: bigint): Promise => { - try { - const accountInfo = - await algokit.client.algod.accountInformation(address) - return (accountInfo.assets ?? []).some( - a => BigInt(a.assetId) === assetId, - ) - } catch { - return false - } - }, - [algokit], - ) - - const claimAsset = useCallback( - async (params: ClaimParams): Promise<{ txIds: string[] }> => { - const { sender, assetId, shouldClaimAlgo } = params - const arc59Config = isMainnet - ? config.arc59.mainnet - : config.arc59.testnet - - const suggestedParams = await algokit.getSuggestedParams() - - const appClient = new ARC59Client({ - appId: arc59Config.appId, - algorand: algokit, - defaultSender: sender, - }) - - const composer = algokit.newGroup() - const optedIn = await isOptedInToAsset(sender, assetId) - - // Calculate main call fee dynamically - // Base: 3 * minFee (claim itself + 2 inner txns) - let claimFee = BigInt(BASE_CLAIM_TX_COUNT) * suggestedParams.minFee - if (shouldClaimAlgo) - claimFee += BigInt(CLAIM_ALGO_TX_COUNT) * suggestedParams.minFee - if (!optedIn) claimFee += suggestedParams.minFee - - if (shouldClaimAlgo) { - composer.addAppCallMethodCall( - await appClient.params.arc59_claimAlgo({ - args: [], +export const useArc59ClaimTransaction = + (): UseArc59ClaimTransactionResult => { + const { isMainnet } = useNetwork() + const algokit = useAlgorandClient() + + const isOptedInToAsset = useCallback( + async (address: string, assetId: bigint): Promise => { + try { + const accountInfo = + await algokit.client.algod.accountInformation(address) + return (accountInfo.assets ?? []).some( + a => BigInt(a.assetId) === assetId, + ) + } catch { + return false + } + }, + [algokit], + ) + + const buildClaimAssetTxs = useCallback( + async (params: ClaimParams): Promise => { + const { sender, assetId, shouldClaimAlgo } = params + const arc59Config = isMainnet + ? config.arc59.mainnet + : config.arc59.testnet + + const suggestedParams = await algokit.getSuggestedParams() + + const appClient = new ARC59Client({ + appId: arc59Config.appId, + algorand: algokit, + defaultSender: sender, + }) + + const composer = algokit.newGroup() + const optedIn = await isOptedInToAsset(sender, assetId) + + // Calculate main call fee dynamically + // Base: 3 * minFee (claim itself + 2 inner txns) + let claimFee = + BigInt(BASE_CLAIM_TX_COUNT) * suggestedParams.minFee + if (shouldClaimAlgo) + claimFee += + BigInt(CLAIM_ALGO_TX_COUNT) * suggestedParams.minFee + if (!optedIn) claimFee += suggestedParams.minFee + + if (shouldClaimAlgo) { + composer.addAppCallMethodCall( + await appClient.params.arc59_claimAlgo({ + args: [], + staticFee: 0n.microAlgo(), + }), + ) + } + + if (!optedIn) { + composer.addAssetOptIn({ + sender, + assetId, staticFee: 0n.microAlgo(), + }) + } + + composer.addAppCallMethodCall( + await appClient.params.arc59_claim({ + args: [assetId], + staticFee: claimFee.microAlgo(), }), ) - } - if (!optedIn) { - composer.addAssetOptIn({ - sender, - assetId, - staticFee: 0n.microAlgo(), + const { transactions } = await composer.build() + return transactions.map(t => t.txn) + }, + [algokit, isMainnet, isOptedInToAsset], + ) + + const buildRejectAssetTxs = useCallback( + async (params: RejectParams): Promise => { + const { sender, assetId, shouldClaimAlgo } = params + const arc59Config = isMainnet + ? config.arc59.mainnet + : config.arc59.testnet + + const suggestedParams = await algokit.getSuggestedParams() + + const appClient = new ARC59Client({ + appId: arc59Config.appId, + algorand: algokit, + defaultSender: sender, }) - } - - composer.addAppCallMethodCall( - await appClient.params.arc59_claim({ - args: [assetId], - staticFee: claimFee.microAlgo(), - }), - ) - - const result = await composer.send() - return { txIds: result.txIds } - }, - [algokit, isMainnet, isOptedInToAsset], - ) - - const rejectAsset = useCallback( - async (params: RejectParams): Promise<{ txIds: string[] }> => { - const { sender, assetId, shouldClaimAlgo } = params - const arc59Config = isMainnet - ? config.arc59.mainnet - : config.arc59.testnet - - const suggestedParams = await algokit.getSuggestedParams() - - const appClient = new ARC59Client({ - appId: arc59Config.appId, - algorand: algokit, - defaultSender: sender, - }) - - const composer = algokit.newGroup() - - // Calculate main call fee dynamically - // Base: 3 * minFee (reject itself + 2 inner txns) - let rejectFee = - BigInt(BASE_REJECT_TX_COUNT) * suggestedParams.minFee - if (shouldClaimAlgo) - rejectFee += - BigInt(CLAIM_ALGO_TX_COUNT) * suggestedParams.minFee - - if (shouldClaimAlgo) { + + const composer = algokit.newGroup() + + // Calculate main call fee dynamically + // Base: 3 * minFee (reject itself + 2 inner txns) + let rejectFee = + BigInt(BASE_REJECT_TX_COUNT) * suggestedParams.minFee + if (shouldClaimAlgo) + rejectFee += + BigInt(CLAIM_ALGO_TX_COUNT) * suggestedParams.minFee + + if (shouldClaimAlgo) { + composer.addAppCallMethodCall( + await appClient.params.arc59_claimAlgo({ + args: [], + staticFee: 0n.microAlgo(), + }), + ) + } + composer.addAppCallMethodCall( - await appClient.params.arc59_claimAlgo({ - args: [], - staticFee: 0n.microAlgo(), + await appClient.params.arc59_reject({ + args: [assetId], + staticFee: rejectFee.microAlgo(), }), ) - } - - composer.addAppCallMethodCall( - await appClient.params.arc59_reject({ - args: [assetId], - staticFee: rejectFee.microAlgo(), - }), - ) - - const result = await composer.send() - return { txIds: result.txIds } - }, - [algokit, isMainnet], - ) - - return { claimAsset, rejectAsset } -} + + const { transactions } = await composer.build() + return transactions.map(t => t.txn) + }, + [algokit, isMainnet], + ) + + return { buildClaimAssetTxs, buildRejectAssetTxs } + } diff --git a/packages/asa-inbox/src/hooks/useArc59SendTransaction.ts b/packages/asa-inbox/src/hooks/useArc59SendTransaction.ts index e0c05fc44..35fa97c2d 100644 --- a/packages/asa-inbox/src/hooks/useArc59SendTransaction.ts +++ b/packages/asa-inbox/src/hooks/useArc59SendTransaction.ts @@ -11,12 +11,12 @@ */ import { useCallback } from 'react' -import { useNetwork } from '@perawallet/wallet-core-blockchain' -import { config } from '@perawallet/wallet-core-config' import { useAlgorandClient, - type PeraTransactionSigner, + useNetwork, } from '@perawallet/wallet-core-blockchain' +import type { PeraTransaction } from '@perawallet/wallet-core-blockchain' +import { config } from '@perawallet/wallet-core-config' import type { Arc59SendSummaryResponse } from '../api' import { ARC59Client } from '../clients' @@ -28,18 +28,18 @@ type SendViaInboxParams = { summary: Arc59SendSummaryResponse } -type useArc59SendTransactionResult = { - sendViaInbox: (params: SendViaInboxParams) => Promise<{ txIds: string[] }> +type UseArc59SendTransactionResult = { + buildSendViaInboxTxs: ( + params: SendViaInboxParams, + ) => Promise } -export const useArc59SendTransaction = ( - signer: PeraTransactionSigner, -): useArc59SendTransactionResult => { +export const useArc59SendTransaction = (): UseArc59SendTransactionResult => { const { isMainnet } = useNetwork() - const algokit = useAlgorandClient(signer) + const algokit = useAlgorandClient() - const sendViaInbox = useCallback( - async (params: SendViaInboxParams): Promise<{ txIds: string[] }> => { + const buildSendViaInboxTxs = useCallback( + async (params: SendViaInboxParams): Promise => { const { sender, receiver, assetId, amount, summary } = params const arc59Config = isMainnet ? config.arc59.mainnet @@ -98,11 +98,11 @@ export const useArc59SendTransaction = ( }), ) - const result = await composer.send() - return { txIds: result.txIds } + const { transactions } = await composer.build() + return transactions.map(t => t.txn) }, [algokit, isMainnet], ) - return { sendViaInbox } + return { buildSendViaInboxTxs } } From 2282f4a41f3de5f4bedebcf18018498ce86f9e30 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:44:20 +0100 Subject: [PATCH 07/14] feat(transactions): route send flow through signing pipeline [PERA-3966] --- packages/transactions/package.json | 1 + .../__tests__/useTransactionSendFlow.spec.tsx | 534 +++++------------- .../src/hooks/useTransactionSendFlow.ts | 170 +++--- packages/transactions/vitest.setup.ts | 1 + pnpm-lock.yaml | 5 +- 5 files changed, 261 insertions(+), 450 deletions(-) diff --git a/packages/transactions/package.json b/packages/transactions/package.json index f3e1714c7..04fe1d7b1 100644 --- a/packages/transactions/package.json +++ b/packages/transactions/package.json @@ -33,6 +33,7 @@ "zod": "catalog:" }, "devDependencies": { + "@algorandfoundation/algokit-utils": "catalog:", "@perawallet/wallet-core-devtools": "workspace:*", "@testing-library/react": "catalog:", "@types/react": "catalog:", diff --git a/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx b/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx index ad35987be..0a4a0f54f 100644 --- a/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx +++ b/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx @@ -13,437 +13,205 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { Decimal } from 'decimal.js' - import { useTransactionSendFlow } from '../useTransactionSendFlow' -import type { - SendTransactionParams, - SendClaimParams, -} from '../useTransactionSendFlow' -const mockPayment = vi.fn() -const mockAssetTransfer = vi.fn() -const mockSendExpress = vi.fn() -const mockSendViaInbox = vi.fn() -const mockClaimAsset = vi.fn() -const mockRejectAsset = vi.fn() -const mockSignTransactions = vi.fn() +const mockSubmit = vi.fn() +const mockBuildSendViaInbox = vi.fn() +const mockBuildClaimAsset = vi.fn() +const mockBuildRejectAsset = vi.fn() const mockAccountInformation = vi.fn() +const mockGetSuggestedParams = vi.fn() +const mockNewGroup = vi.fn() +const mockBuild = vi.fn() const mockAddPayment = vi.fn() -const mockAddAssetOptIn = vi.fn() const mockAddAssetTransfer = vi.fn() -const mockComposerSend = vi.fn() +const mockAddAssetOptIn = vi.fn() vi.mock('@perawallet/wallet-core-signing', () => ({ - useTransactionSigner: () => ({ - signTransactions: mockSignTransactions, - }), -})) - -vi.mock('@perawallet/wallet-core-blockchain', () => ({ - useAlgorandClient: () => { - const composer = { - addPayment: (...args: unknown[]) => { - mockAddPayment(...args) - return composer - }, - addAssetOptIn: (...args: unknown[]) => { - mockAddAssetOptIn(...args) - return composer - }, - addAssetTransfer: (...args: unknown[]) => { - mockAddAssetTransfer(...args) - return composer - }, - send: mockComposerSend, - } - return { - send: { - payment: mockPayment, - assetTransfer: mockAssetTransfer, - }, - client: { - algod: { - accountInformation: mockAccountInformation, - }, - }, - getSuggestedParams: vi.fn().mockResolvedValue({ minFee: 1000n }), - newGroup: () => composer, - } - }, - useExpressTransaction: () => ({ - sendExpress: mockSendExpress, - }), - displayUnitsToBaseUnits: (amount: string, decimals: number) => { - const factor = 10 ** decimals - return String(Math.round(parseFloat(amount) * factor)) - }, - ASSET_MBR: 100000n, + useSignAndSubmitGroup: () => ({ submit: mockSubmit }), })) vi.mock('@perawallet/wallet-core-asa-inbox', () => ({ useArc59SendTransaction: () => ({ - sendViaInbox: mockSendViaInbox, + buildSendViaInboxTxs: mockBuildSendViaInbox, }), useArc59ClaimTransaction: () => ({ - claimAsset: mockClaimAsset, - rejectAsset: mockRejectAsset, + buildClaimAssetTxs: mockBuildClaimAsset, + buildRejectAssetTxs: mockBuildRejectAsset, }), })) -vi.mock('@perawallet/wallet-core-assets', () => ({ - ALGO_ASSET_ID: '0', - ALGO_ASSET: { assetId: '0', decimals: 6, name: 'Algo', unitName: 'ALGO' }, - toDecimalUnits: (value: Decimal) => value, +vi.mock('@perawallet/wallet-core-blockchain', () => ({ + useAlgorandClient: () => ({ + client: { algod: { accountInformation: mockAccountInformation } }, + getSuggestedParams: mockGetSuggestedParams, + newGroup: () => { + const group = { + addPayment: mockAddPayment.mockReturnThis(), + addAssetTransfer: mockAddAssetTransfer.mockReturnThis(), + addAssetOptIn: mockAddAssetOptIn.mockReturnThis(), + build: mockBuild, + } + mockNewGroup(group) + return group + }, + }), + displayUnitsToBaseUnits: (val: Decimal, _decimals: number) => val, + ASSET_MBR: 100000n, })) -const ALGO_ASSET = { - assetId: '0', - decimals: 6, - name: 'Algo', - unitName: 'ALGO', -} -const TEST_ASSET = { - assetId: '123', - decimals: 6, - name: 'Test Asset', - unitName: 'TEST', -} +vi.mock('@perawallet/wallet-core-assets', () => ({ + ALGO_ASSET_ID: 0n, + ALGO_ASSET: { assetId: 0n, decimals: 6, name: 'Algo', unitName: 'ALGO' }, +})) -// Mock BigInt to return an object with microAlgo method for AlgoKit compatibility -const originalBigInt = global.BigInt -const mockBigIntFn = vi.fn((value: string | number | bigint) => { - const bigIntValue = originalBigInt(value) - return { - microAlgo: () => bigIntValue, - valueOf: () => bigIntValue, - toString: () => bigIntValue.toString(), - } -}) -vi.stubGlobal('BigInt', mockBigIntFn) +const TXN = { sender: 'SENDER' } as unknown describe('useTransactionSendFlow', () => { beforeEach(() => { vi.clearAllMocks() - }) - - describe('normal ALGO payment', () => { - it('should call algokit.send.payment and return first txId', async () => { - mockPayment.mockResolvedValue({ txIds: ['ALGO_TX_ID'] }) - - const { result } = renderHook(() => useTransactionSendFlow()) - - const params: SendTransactionParams = { - sendMode: 'normal', - sender: { address: 'SENDER_ADDR', name: 'Test' } as never, - receiver: 'RECEIVER_ADDR', - asset: ALGO_ASSET as never, - amount: new Decimal(5), - note: 'test note', - } - - let txId: string | undefined - await act(async () => { - txId = await result.current.execute({ params }) - }) - - expect(mockPayment).toHaveBeenCalledWith( - expect.objectContaining({ - sender: 'SENDER_ADDR', - receiver: 'RECEIVER_ADDR', - note: 'test note', - }), - ) - expect(txId).toBe('ALGO_TX_ID') - }) - - it('should handle closeRemainderTo when isCloseAccount is true', async () => { - mockPayment.mockResolvedValue({ txIds: ['CLOSE_TX_ID'] }) - - const { result } = renderHook(() => useTransactionSendFlow()) - - const params: SendTransactionParams = { - sendMode: 'normal', - sender: { address: 'SENDER_ADDR', name: 'Test' } as never, - receiver: 'RECEIVER_ADDR', - asset: ALGO_ASSET as never, - amount: new Decimal(0), - isCloseAccount: true, - } - - let txId: string | undefined - await act(async () => { - txId = await result.current.execute({ params }) - }) - - expect(mockPayment).toHaveBeenCalledWith( - expect.objectContaining({ - sender: 'SENDER_ADDR', - receiver: 'RECEIVER_ADDR', - closeRemainderTo: 'RECEIVER_ADDR', - }), - ) - expect(txId).toBe('CLOSE_TX_ID') + mockGetSuggestedParams.mockResolvedValue({ minFee: 1000n }) + mockBuild.mockResolvedValue({ transactions: [{ txn: TXN }] }) + mockSubmit.mockResolvedValue({ txIds: ['tx1'] }) + mockAccountInformation.mockResolvedValue({ + amount: 0n, + minBalance: 100000n, }) + mockBuildSendViaInbox.mockResolvedValue([TXN]) + mockBuildClaimAsset.mockResolvedValue([TXN]) + mockBuildRejectAsset.mockResolvedValue([TXN]) }) - describe('normal ASA transfer', () => { - it('should call algokit.send.assetTransfer and return first txId', async () => { - mockAssetTransfer.mockResolvedValue({ txIds: ['ASA_TX_ID'] }) - - const { result } = renderHook(() => useTransactionSendFlow()) - - const params: SendTransactionParams = { - sendMode: 'normal', - sender: { address: 'SENDER_ADDR', name: 'Test' } as never, - receiver: 'RECEIVER_ADDR', - asset: TEST_ASSET as never, - amount: new Decimal(10), - note: 'asa note', - } - - let txId: string | undefined - await act(async () => { - txId = await result.current.execute({ params }) + it('normal ALGO send: builds payment + submits via pipeline', async () => { + const { result } = renderHook(() => useTransactionSendFlow()) + await act(async () => { + const id = await result.current.execute({ + params: { + sendMode: 'normal', + sender: { address: 'A' } as any, + receiver: 'B', + asset: { assetId: 0n, decimals: 6 } as any, + amount: new Decimal(1), + }, }) - - expect(mockAssetTransfer).toHaveBeenCalledWith( - expect.objectContaining({ - sender: 'SENDER_ADDR', - receiver: 'RECEIVER_ADDR', - note: 'asa note', - }), - ) - expect(txId).toBe('ASA_TX_ID') + expect(id).toBe('tx1') }) + expect(mockAddPayment).toHaveBeenCalled() + expect(mockSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + unsignedTxs: [TXN], + source: { name: 'send-transaction', description: 'Send transaction' }, + }), + ) }) - describe('express send', () => { - it('should call sendExpress and return last txId', async () => { - mockAccountInformation.mockResolvedValue({ - amount: 500000n, - minBalance: 100000n, - }) - mockComposerSend.mockResolvedValue({ - txIds: ['EXPRESS_TX_1', 'EXPRESS_TX_2'], - }) - - const { result } = renderHook(() => useTransactionSendFlow()) - - const params: SendTransactionParams = { - sendMode: 'express', - sender: { address: 'SENDER_ADDR', name: 'Test' } as never, - receiver: 'RECEIVER_ADDR', - asset: TEST_ASSET as never, - amount: new Decimal(5), - } - - let txId: string | undefined - await act(async () => { - txId = await result.current.execute({ params }) + it('normal ASA send: builds asset transfer + submits via pipeline', async () => { + const { result } = renderHook(() => useTransactionSendFlow()) + await act(async () => { + await result.current.execute({ + params: { + sendMode: 'normal', + sender: { address: 'A' } as any, + receiver: 'B', + asset: { assetId: 99n, decimals: 0 } as any, + amount: new Decimal(1), + }, }) - - expect(mockAccountInformation).toHaveBeenCalledWith('RECEIVER_ADDR') - expect(txId).toBe('EXPRESS_TX_2') }) + expect(mockAddAssetTransfer).toHaveBeenCalled() + expect(mockSubmit).toHaveBeenCalledTimes(1) }) - describe('arc59 send', () => { - it('should call sendViaInbox and return last txId', async () => { - mockSendViaInbox.mockResolvedValue({ - txIds: ['ARC59_TX_1', 'ARC59_TX_2'], - }) - - const arc59Summary = { - is_arc59_opted_in: true, - minimum_balance_requirement: 100000, - inner_tx_count: 2, - total_protocol_and_mbr_fee: 200000, - inbox_address: 'INBOX_ADDR', - algo_fund_amount: 0, - warning_message: null, - } - - const { result } = renderHook(() => useTransactionSendFlow()) - - const params: SendTransactionParams = { - sendMode: 'sendArc59', - sender: { address: 'SENDER_ADDR', name: 'Test' } as never, - receiver: 'RECEIVER_ADDR', - asset: TEST_ASSET as never, - amount: new Decimal(5), - arc59Summary, - } - - let txId: string | undefined - await act(async () => { - txId = await result.current.execute({ params }) - }) - - expect(mockSendViaInbox).toHaveBeenCalledWith( - expect.objectContaining({ - sender: 'SENDER_ADDR', - receiver: 'RECEIVER_ADDR', - summary: arc59Summary, - }), - ) - expect(txId).toBe('ARC59_TX_2') + it('express send: includes funding payment when receiver is underfunded', async () => { + mockAccountInformation.mockResolvedValueOnce({ + amount: 0n, + minBalance: 100000n, }) - - it('should throw when arc59Summary is missing', async () => { - const { result } = renderHook(() => useTransactionSendFlow()) - - const params: SendTransactionParams = { - sendMode: 'sendArc59', - sender: { address: 'SENDER_ADDR', name: 'Test' } as never, - receiver: 'RECEIVER_ADDR', - asset: TEST_ASSET as never, - amount: new Decimal(5), - } - - let error: Error | undefined - await act(async () => { - try { - await result.current.execute({ params }) - } catch (e) { - error = e as Error - } + const { result } = renderHook(() => useTransactionSendFlow()) + await act(async () => { + await result.current.execute({ + params: { + sendMode: 'express', + sender: { address: 'A' } as any, + receiver: 'B', + asset: { assetId: 99n, decimals: 0 } as any, + amount: new Decimal(1), + }, }) - - expect(error).toBeDefined() - expect(error).toBeInstanceOf(Error) }) + expect(mockAddPayment).toHaveBeenCalled() + expect(mockAddAssetOptIn).toHaveBeenCalled() + expect(mockAddAssetTransfer).toHaveBeenCalled() + expect(mockSubmit).toHaveBeenCalledTimes(1) }) - describe('claim transaction', () => { - it('should call claimAsset and return last txId', async () => { - mockClaimAsset.mockResolvedValue({ - txIds: ['CLAIM_TX_1', 'CLAIM_TX_2'], - }) - - const { result } = renderHook(() => useTransactionSendFlow()) - - const params: SendClaimParams = { - sendMode: 'claimArc59', - sender: { address: 'SENDER_ADDR', name: 'Test' } as never, - asset: TEST_ASSET as never, - shouldClaimAlgo: true, - } - - let txId: string | undefined - await act(async () => { - txId = await result.current.execute({ params }) - }) - - expect(mockClaimAsset).toHaveBeenCalledWith( - expect.objectContaining({ - sender: 'SENDER_ADDR', - shouldClaimAlgo: true, - }), - ) - expect(txId).toBe('CLAIM_TX_2') - }) - - it('should call rejectAsset and return last txId', async () => { - mockRejectAsset.mockResolvedValue({ - txIds: ['REJECT_TX_1', 'REJECT_TX_2'], - }) - - const { result } = renderHook(() => useTransactionSendFlow()) - - const params: SendClaimParams = { - sendMode: 'rejectArc59', - sender: { address: 'SENDER_ADDR', name: 'Test' } as never, - asset: TEST_ASSET as never, - shouldClaimAlgo: false, - } - - let txId: string | undefined - await act(async () => { - txId = await result.current.execute({ params }) + it('sendArc59: delegates building to ARC-59 hook + submits via pipeline', async () => { + const { result } = renderHook(() => useTransactionSendFlow()) + await act(async () => { + await result.current.execute({ + params: { + sendMode: 'sendArc59', + sender: { address: 'A' } as any, + receiver: 'B', + asset: { assetId: 99n, decimals: 0 } as any, + amount: new Decimal(1), + arc59Summary: { + algo_fund_amount: 0, + minimum_balance_requirement: 0, + is_arc59_opted_in: true, + inner_tx_count: 1, + } as any, + }, }) - - expect(mockRejectAsset).toHaveBeenCalledWith( - expect.objectContaining({ - sender: 'SENDER_ADDR', - shouldClaimAlgo: false, - }), - ) - expect(txId).toBe('REJECT_TX_2') }) + expect(mockBuildSendViaInbox).toHaveBeenCalledTimes(1) + expect(mockSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + unsignedTxs: [TXN], + source: { name: 'send-transaction', description: 'Send transaction' }, + }), + ) }) - describe('null transaction', () => { - it('should throw when params is null', async () => { - const { result } = renderHook(() => useTransactionSendFlow()) - - let error: Error | undefined - await act(async () => { - try { - await result.current.execute({ params: null }) - } catch (e) { - error = e as Error - } + it('claimArc59: delegates building to ARC-59 hook + submits via pipeline', async () => { + const { result } = renderHook(() => useTransactionSendFlow()) + await act(async () => { + await result.current.execute({ + params: { + sendMode: 'claimArc59', + sender: { address: 'A' } as any, + asset: { assetId: 99n, decimals: 0 } as any, + shouldClaimAlgo: false, + }, }) - - expect(error).toBeDefined() - expect(mockPayment).not.toHaveBeenCalled() - expect(mockAssetTransfer).not.toHaveBeenCalled() - expect(mockSendExpress).not.toHaveBeenCalled() - expect(mockSendViaInbox).not.toHaveBeenCalled() - expect(mockClaimAsset).not.toHaveBeenCalled() - expect(mockRejectAsset).not.toHaveBeenCalled() }) + expect(mockBuildClaimAsset).toHaveBeenCalledTimes(1) + expect(mockSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + unsignedTxs: [TXN], + source: { name: 'send-transaction', description: 'Send transaction' }, + }), + ) }) - describe('error handling', () => { - it('should propagate errors when execute rejects', async () => { - const networkError = new Error('Network error') - mockPayment.mockRejectedValue(networkError) - - const { result } = renderHook(() => useTransactionSendFlow()) - - const params: SendTransactionParams = { - sendMode: 'normal', - sender: { address: 'SENDER_ADDR', name: 'Test' } as never, - receiver: 'RECEIVER_ADDR', - asset: ALGO_ASSET as never, - amount: new Decimal(5), - } - - let error: Error | undefined - await act(async () => { - try { - await result.current.execute({ params }) - } catch (e) { - error = e as Error - } - }) - - expect(error).toBe(networkError) - }) - - it('should throw InvalidSendParamsError when asset is missing', async () => { - const { result } = renderHook(() => useTransactionSendFlow()) - - const params: SendTransactionParams = { - sendMode: 'normal', - sender: { address: 'SENDER_ADDR', name: 'Test' } as never, - receiver: 'RECEIVER_ADDR', - asset: undefined, - amount: new Decimal(5), - } - - let error: Error | undefined - await act(async () => { - try { - await result.current.execute({ params }) - } catch (e) { - error = e as Error - } + it('rejectArc59: delegates building to ARC-59 hook + submits via pipeline', async () => { + const { result } = renderHook(() => useTransactionSendFlow()) + await act(async () => { + await result.current.execute({ + params: { + sendMode: 'rejectArc59', + sender: { address: 'A' } as any, + asset: { assetId: 99n, decimals: 0 } as any, + shouldClaimAlgo: false, + }, }) - - expect(error).toBeDefined() - expect(error?.name).toBe('InvalidSendParamsError') }) + expect(mockBuildRejectAsset).toHaveBeenCalledTimes(1) + expect(mockSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + unsignedTxs: [TXN], + source: { name: 'send-transaction', description: 'Send transaction' }, + }), + ) }) }) diff --git a/packages/transactions/src/hooks/useTransactionSendFlow.ts b/packages/transactions/src/hooks/useTransactionSendFlow.ts index 206690850..b51724fcb 100644 --- a/packages/transactions/src/hooks/useTransactionSendFlow.ts +++ b/packages/transactions/src/hooks/useTransactionSendFlow.ts @@ -24,7 +24,8 @@ import { displayUnitsToBaseUnits, useAlgorandClient, } from '@perawallet/wallet-core-blockchain' -import { useTransactionSigner } from '@perawallet/wallet-core-signing' +import type { PeraTransaction } from '@perawallet/wallet-core-blockchain' +import { useSignAndSubmitGroup } from '@perawallet/wallet-core-signing' import { WalletAccount } from '@perawallet/wallet-core-accounts' import { InvalidSendParamsError } from '../errors' import type { Nullable } from '@perawallet/wallet-core-shared' @@ -52,26 +53,29 @@ type SendClaimParams = BaseSendParams & { type SendParams = SendTransactionParams | SendClaimParams -type SendExpressParams = { - sender: string - receiver: string - assetId: bigint - amount: bigint -} - type UseTransactionSendFlowParams = { params: Nullable } +const SEND_SOURCE = { + name: 'send-transaction', + description: 'Send transaction', +} + export const useTransactionSendFlow = () => { - const { signTransactions } = useTransactionSigner() - const algokit = useAlgorandClient(signTransactions) - const { sendViaInbox } = useArc59SendTransaction(signTransactions) - const { claimAsset, rejectAsset } = - useArc59ClaimTransaction(signTransactions) - - const sendExpress = useCallback( - async (params: SendExpressParams): Promise<{ txIds: string[] }> => { + const algokit = useAlgorandClient() + const { submit } = useSignAndSubmitGroup() + const { buildSendViaInboxTxs } = useArc59SendTransaction() + const { buildClaimAssetTxs, buildRejectAssetTxs } = + useArc59ClaimTransaction() + + const buildExpressTxs = useCallback( + async (params: { + sender: string + receiver: string + assetId: bigint + amount: bigint + }): Promise => { const { sender, receiver, assetId, amount } = params // Look up receiver's current balance to determine funding needed @@ -100,19 +104,56 @@ export const useTransactionSendFlow = () => { } composer - .addAssetOptIn({ - sender: receiver, - assetId, + .addAssetOptIn({ sender: receiver, assetId }) + .addAssetTransfer({ sender, receiver, amount, assetId }) + + const { transactions } = await composer.build() + return transactions.map(t => t.txn) + }, + [algokit], + ) + + const buildNormalTxs = useCallback( + async (params: SendTransactionParams): Promise => { + if ( + !params.asset || + params.asset.assetId == null || + !params.sender || + !params.receiver || + params.amount == null + ) { + throw new InvalidSendParamsError() + } + + const assetDecimals = params.asset?.decimals ?? 0 + const amountInBaseUnits = BigInt( + displayUnitsToBaseUnits(params.amount, assetDecimals).toString(), + ) + + const composer = algokit.newGroup() + if (params.asset.assetId === ALGO_ASSET_ID) { + composer.addPayment({ + sender: params.sender.address, + receiver: params.receiver, + amount: params.isCloseAccount + ? BigInt(0).microAlgo() + : amountInBaseUnits.microAlgo(), + ...(params.isCloseAccount && { + closeRemainderTo: params.receiver, + }), + note: params.note, }) - .addAssetTransfer({ - sender, - receiver, - amount, - assetId, + } else { + composer.addAssetTransfer({ + sender: params.sender.address, + receiver: params.receiver, + amount: amountInBaseUnits, + assetId: BigInt(params.asset.assetId), + note: params.note, }) - - const result = await composer.send() - return { txIds: result.txIds } + } + const { transactions } = await composer.build() + return transactions.map(t => t.txn) }, [algokit], ) @@ -121,7 +162,7 @@ export const useTransactionSendFlow = () => { async (params: SendTransactionParams): Promise => { if ( !params.asset || - !params.asset.assetId || + params.asset.assetId == null || !params.sender || !params.receiver || params.amount == null @@ -130,67 +171,58 @@ export const useTransactionSendFlow = () => { } const assetDecimals = params.asset?.decimals ?? 0 - const amountInBaseUnits = BigInt( - displayUnitsToBaseUnits( - params.amount, - assetDecimals ?? 0, - ).toString(), + displayUnitsToBaseUnits(params.amount, assetDecimals).toString(), ) const assetId = BigInt(params.asset.assetId) switch (params.sendMode) { case 'express': { - const result = await sendExpress({ + const unsignedTxs = await buildExpressTxs({ sender: params.sender.address, receiver: params.receiver, assetId, amount: amountInBaseUnits, }) + const result = await submit({ + unsignedTxs, + source: SEND_SOURCE, + }) return result.txIds[result.txIds.length - 1] } case 'sendArc59': { if (!params.arc59Summary) { throw new InvalidSendParamsError() } - - const result = await sendViaInbox({ + const unsignedTxs = await buildSendViaInboxTxs({ sender: params.sender.address, receiver: params.receiver, assetId, amount: amountInBaseUnits, summary: params.arc59Summary, }) + const result = await submit({ + unsignedTxs, + source: SEND_SOURCE, + }) return result.txIds[result.txIds.length - 1] } case 'normal': { - if (params.asset.assetId === ALGO_ASSET_ID) { - const result = await algokit.send.payment({ - sender: params.sender.address, - receiver: params.receiver, - amount: params.isCloseAccount - ? BigInt(0).microAlgo() - : amountInBaseUnits.microAlgo(), - ...(params.isCloseAccount && { - closeRemainderTo: params.receiver, - }), - note: params.note, - }) - return result.txIds[0] - } else { - const result = await algokit.send.assetTransfer({ - sender: params.sender.address, - receiver: params.receiver, - amount: amountInBaseUnits, - assetId, - note: params.note, - }) - return result.txIds[0] - } + const unsignedTxs = await buildNormalTxs(params) + const result = await submit({ + unsignedTxs, + source: SEND_SOURCE, + }) + return result.txIds[0] } } }, - [sendExpress, algokit, sendViaInbox], + [ + buildExpressTxs, + buildSendViaInboxTxs, + buildNormalTxs, + submit, + ], ) const executeArc59 = useCallback( @@ -200,22 +232,30 @@ export const useTransactionSendFlow = () => { } if (params.sendMode === 'claimArc59') { - const result = await claimAsset({ + const unsignedTxs = await buildClaimAssetTxs({ sender: params.sender.address, assetId: BigInt(params.asset.assetId), shouldClaimAlgo: params.shouldClaimAlgo, }) + const result = await submit({ + unsignedTxs, + source: SEND_SOURCE, + }) return result.txIds[result.txIds.length - 1] } else { - const result = await rejectAsset({ + const unsignedTxs = await buildRejectAssetTxs({ sender: params.sender.address, assetId: BigInt(params.asset.assetId), shouldClaimAlgo: params.shouldClaimAlgo, }) + const result = await submit({ + unsignedTxs, + source: SEND_SOURCE, + }) return result.txIds[result.txIds.length - 1] } }, - [claimAsset, rejectAsset], + [buildClaimAssetTxs, buildRejectAssetTxs, submit], ) const execute = useCallback( @@ -223,15 +263,13 @@ export const useTransactionSendFlow = () => { if (!params) { throw new InvalidSendParamsError() } - if ( params.sendMode === 'claimArc59' || params.sendMode === 'rejectArc59' ) { return await executeArc59(params) - } else { - return await executeSend(params as SendTransactionParams) } + return await executeSend(params as SendTransactionParams) }, [executeArc59, executeSend], ) diff --git a/packages/transactions/vitest.setup.ts b/packages/transactions/vitest.setup.ts index d4741d2ef..06579a5f2 100644 --- a/packages/transactions/vitest.setup.ts +++ b/packages/transactions/vitest.setup.ts @@ -10,6 +10,7 @@ limitations under the License */ +import '@algorandfoundation/algokit-utils' import { vi } from 'vitest' const store = new Map() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88c5f5213..dc6a53350 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2487,6 +2487,9 @@ importers: specifier: 'catalog:' version: 4.3.6 devDependencies: + '@algorandfoundation/algokit-utils': + specifier: 'catalog:' + version: 10.0.0-beta.1 '@perawallet/wallet-core-devtools': specifier: workspace:* version: link:../devtools @@ -13103,7 +13106,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.3)(jiti@2.5.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@27.4.0(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.3)(jiti@2.5.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.4': dependencies: From ab2eb8ca531495ade9da67d84d3e6ca808238543 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:52:06 +0100 Subject: [PATCH 08/14] fix(transactions): use strict equality and tighten send-flow assertions [PERA-3966] --- .../__tests__/useTransactionSendFlow.spec.tsx | 14 ++++++++++++-- .../src/hooks/useTransactionSendFlow.ts | 11 +++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx b/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx index 0a4a0f54f..9cf791064 100644 --- a/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx +++ b/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx @@ -119,7 +119,12 @@ describe('useTransactionSendFlow', () => { }) }) expect(mockAddAssetTransfer).toHaveBeenCalled() - expect(mockSubmit).toHaveBeenCalledTimes(1) + expect(mockSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + unsignedTxs: [TXN], + source: { name: 'send-transaction', description: 'Send transaction' }, + }), + ) }) it('express send: includes funding payment when receiver is underfunded', async () => { @@ -142,7 +147,12 @@ describe('useTransactionSendFlow', () => { expect(mockAddPayment).toHaveBeenCalled() expect(mockAddAssetOptIn).toHaveBeenCalled() expect(mockAddAssetTransfer).toHaveBeenCalled() - expect(mockSubmit).toHaveBeenCalledTimes(1) + expect(mockSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + unsignedTxs: [TXN], + source: { name: 'send-transaction', description: 'Send transaction' }, + }), + ) }) it('sendArc59: delegates building to ARC-59 hook + submits via pipeline', async () => { diff --git a/packages/transactions/src/hooks/useTransactionSendFlow.ts b/packages/transactions/src/hooks/useTransactionSendFlow.ts index b51724fcb..14ef077b8 100644 --- a/packages/transactions/src/hooks/useTransactionSendFlow.ts +++ b/packages/transactions/src/hooks/useTransactionSendFlow.ts @@ -13,7 +13,8 @@ import { useCallback } from 'react' import { Decimal } from 'decimal.js' -import { ALGO_ASSET_ID, PeraAsset } from '@perawallet/wallet-core-assets' +import { ALGO_ASSET_ID } from '@perawallet/wallet-core-assets' +import type { PeraAsset } from '@perawallet/wallet-core-assets' import type { Arc59SendSummaryResponse } from '@perawallet/wallet-core-asa-inbox' import { useArc59SendTransaction, @@ -26,7 +27,7 @@ import { } from '@perawallet/wallet-core-blockchain' import type { PeraTransaction } from '@perawallet/wallet-core-blockchain' import { useSignAndSubmitGroup } from '@perawallet/wallet-core-signing' -import { WalletAccount } from '@perawallet/wallet-core-accounts' +import type { WalletAccount } from '@perawallet/wallet-core-accounts' import { InvalidSendParamsError } from '../errors' import type { Nullable } from '@perawallet/wallet-core-shared' @@ -117,10 +118,9 @@ export const useTransactionSendFlow = () => { async (params: SendTransactionParams): Promise => { if ( !params.asset || - params.asset.assetId == null || !params.sender || !params.receiver || - params.amount == null + params.amount === undefined ) { throw new InvalidSendParamsError() } @@ -162,10 +162,9 @@ export const useTransactionSendFlow = () => { async (params: SendTransactionParams): Promise => { if ( !params.asset || - params.asset.assetId == null || !params.sender || !params.receiver || - params.amount == null + params.amount === undefined ) { throw new InvalidSendParamsError() } From 728b05c8f9540044dd5297d318afe8289d904761 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:57:23 +0100 Subject: [PATCH 09/14] chore(signing): drop dead hardware path from useTransactionSigner [PERA-3966] --- .../__tests__/useTransactionSigner.spec.ts | 76 ++----------------- .../signing/src/hooks/useTransactionSigner.ts | 36 +-------- 2 files changed, 9 insertions(+), 103 deletions(-) diff --git a/packages/signing/src/hooks/__tests__/useTransactionSigner.spec.ts b/packages/signing/src/hooks/__tests__/useTransactionSigner.spec.ts index f3db475eb..0616efda7 100644 --- a/packages/signing/src/hooks/__tests__/useTransactionSigner.spec.ts +++ b/packages/signing/src/hooks/__tests__/useTransactionSigner.spec.ts @@ -47,21 +47,7 @@ vi.mock('@perawallet/wallet-core-accounts', async () => { } }) -const mockHardwareTransportProvider = { - connect: vi.fn(), -} -const mockHardwareWalletRegistry = { - getProvider: vi.fn(), -} - -vi.mock('@perawallet/wallet-extension-provider', () => ({ - getProvider: () => ({ - hardwareWalletRegistry: mockHardwareWalletRegistry, - }), -})) - const encodeTransactionMock = vi.fn() -const encodeTransactionRawMock = vi.fn() vi.mock('@perawallet/wallet-core-blockchain', async () => { const actual = await vi.importActual( @@ -71,7 +57,6 @@ vi.mock('@perawallet/wallet-core-blockchain', async () => { ...actual, useTransactionEncoder: () => ({ encodeTransaction: encodeTransactionMock, - encodeTransactionRaw: encodeTransactionRawMock, }), encodeAlgorandAddress: () => 'SENDER_PK', Address: { fromString: (addr: string) => ({ _addr: addr }) }, @@ -136,12 +121,7 @@ describe('useTransactionSigner', () => { mockIsHDWalletAccount.mockReset().mockReturnValue(false) mockIsAlgo25Account.mockReset().mockReturnValue(false) mockIsHardwareWalletAccount.mockReset().mockReturnValue(false) - mockHardwareTransportProvider.connect.mockReset() - mockHardwareWalletRegistry.getProvider.mockReset() encodeTransactionMock.mockReset().mockReturnValue(new Uint8Array([1])) - encodeTransactionRawMock - .mockReset() - .mockReturnValue(new Uint8Array([0x99])) mockAccounts = [] }) @@ -263,62 +243,20 @@ describe('useTransactionSigner', () => { ).rejects.toContain('Unsupported account type') }) - test('signs hardware wallet transactions via hardware registry', async () => { - mockAccounts = [hardwareAccount] - mockIsHardwareWalletAccount.mockImplementation( - acc => acc.type === 'hardware', - ) - const mockTransport = { - getAddress: vi.fn().mockResolvedValue({ - address: 'LEDGER_ADDR', - publicKey: new Uint8Array(32), - accountIndex: 0, - }), - signTransaction: vi.fn().mockResolvedValue(new Uint8Array([0xaa])), - disconnect: vi.fn().mockResolvedValue(undefined), - } - mockHardwareTransportProvider.connect.mockResolvedValue(mockTransport) - mockHardwareWalletRegistry.getProvider.mockReturnValue( - mockHardwareTransportProvider, - ) - - const { result } = renderHook(() => useTransactionSigner()) - const txn = makeTxn('LEDGER_ADDR') - - const signed = await result.current.signTransactions([txn], [0]) - - expect(mockHardwareWalletRegistry.getProvider).toHaveBeenCalledWith( - 'ledger', - ) - expect(mockHardwareTransportProvider.connect).toHaveBeenCalledWith( - 'device-1', - ) - expect(mockTransport.getAddress).toHaveBeenCalledWith(0, false) - // Ledger receives RAW msgpack (no "TX" domain-separation prefix) — - // the device adds the prefix on-device before hashing. - expect(encodeTransactionRawMock).toHaveBeenCalled() - expect(encodeTransactionMock).not.toHaveBeenCalled() - expect(mockTransport.signTransaction).toHaveBeenCalledWith( - 0, - new Uint8Array([0x99]), - ) - expect(mockTransport.disconnect).toHaveBeenCalled() - expect(signed).toHaveLength(1) - expect(signed[0].sig).toEqual(new Uint8Array([0xaa])) - }) - - test('rejects when hardware registry has no provider for manufacturer', async () => { + test('rejects hardware-wallet accounts — those go through the pipeline', async () => { + // Given a HardwareWalletAccount sender, signTransactions must NOT + // attempt to drive the BLE registry directly. The XState pipeline + // routes hardware signing through hardwareSignerActor; this hook is + // local-key only. mockAccounts = [hardwareAccount] mockIsHardwareWalletAccount.mockImplementation( acc => acc.type === 'hardware', ) - mockHardwareWalletRegistry.getProvider.mockReturnValue(undefined) const { result } = renderHook(() => useTransactionSigner()) - const txn = makeTxn('LEDGER_ADDR') await expect( - result.current.signTransactions([txn], [0]), - ).rejects.toThrow('transport_unavailable') + result.current.signTransactions([makeTxn('LEDGER_ADDR')], [0]), + ).rejects.toContain('Unsupported account type') }) }) diff --git a/packages/signing/src/hooks/useTransactionSigner.ts b/packages/signing/src/hooks/useTransactionSigner.ts index 6d2dbe06c..072e06d7a 100644 --- a/packages/signing/src/hooks/useTransactionSigner.ts +++ b/packages/signing/src/hooks/useTransactionSigner.ts @@ -24,17 +24,13 @@ import { import { isAlgo25Account, isHDWalletAccount, - isHardwareWalletAccount, } from '@perawallet/wallet-core-accounts' import type { Algo25Account, - HardwareWalletAccount, HDWalletAccount, WalletAccount, } from '@perawallet/wallet-core-accounts' -import { getProvider } from '@perawallet/wallet-extension-provider' import { SIGNING_KEY_DOMAIN } from '../constants' -import { signTransactionsOnHardwareWallet } from '../pipeline/signing/createHardwareStrategy' export type UseTransactionSignerResult = { signTransactions: ( @@ -46,7 +42,7 @@ export type UseTransactionSignerResult = { export const useTransactionSigner = (): UseTransactionSignerResult => { const accounts = useAccountsStore(state => state.accounts) const { getKeyOrThrow, withHDSession, withAlgo25Session } = useKMS() - const { encodeTransaction, encodeTransactionRaw } = useTransactionEncoder() + const { encodeTransaction } = useTransactionEncoder() const signHDWalletTransactions = useCallback( async ( @@ -127,25 +123,6 @@ export const useTransactionSigner = (): UseTransactionSignerResult => { [encodeTransaction, withAlgo25Session], ) - const signHardwareWalletTransactions = useCallback( - async ( - account: HardwareWalletAccount, - txns: PeraTransactionGroup, - ): Promise => { - const indicesToSign = txns.map((_, index) => index) - return signTransactionsOnHardwareWallet( - account, - txns, - indicesToSign, - { - registry: getProvider().hardwareWalletRegistry, - encodeTransaction: encodeTransactionRaw, - }, - ) - }, - [encodeTransactionRaw], - ) - const signSingleAccountTransactions = useCallback( async ( account: WalletAccount, @@ -176,20 +153,11 @@ export const useTransactionSigner = (): UseTransactionSignerResult => { return signAlgo25Transactions(account as Algo25Account, txns) } - if (isHardwareWalletAccount(account)) { - return signHardwareWalletTransactions(account, txns) - } - return Promise.reject( `Unsupported account type ${account.type} for ${account.address}`, ) }, - [ - accounts, - signHDWalletTransactions, - signAlgo25Transactions, - signHardwareWalletTransactions, - ], + [accounts, signHDWalletTransactions, signAlgo25Transactions], ) const signTransactions = useCallback( From 1b1c8932425373da58150927a70a5fc330329213 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:11:24 +0100 Subject: [PATCH 10/14] chore(format): apply formatter and copyright header pass [PERA-3966] --- .../src/hooks/useArc59ClaimTransaction.ts | 233 +++++++++--------- .../__tests__/useSignAndSubmitGroup.spec.ts | 8 +- .../src/hooks/useSignAndSubmitGroup.ts | 4 +- .../__tests__/useAssetOptOutMutation.spec.ts | 13 +- .../__tests__/useTransactionSendFlow.spec.tsx | 30 ++- .../src/hooks/useAssetOptInMutation.ts | 5 +- .../src/hooks/useTransactionSendFlow.ts | 17 +- 7 files changed, 169 insertions(+), 141 deletions(-) diff --git a/packages/asa-inbox/src/hooks/useArc59ClaimTransaction.ts b/packages/asa-inbox/src/hooks/useArc59ClaimTransaction.ts index ed7ff9164..57645aeec 100644 --- a/packages/asa-inbox/src/hooks/useArc59ClaimTransaction.ts +++ b/packages/asa-inbox/src/hooks/useArc59ClaimTransaction.ts @@ -41,129 +41,126 @@ type UseArc59ClaimTransactionResult = { buildRejectAssetTxs: (params: RejectParams) => Promise } -export const useArc59ClaimTransaction = - (): UseArc59ClaimTransactionResult => { - const { isMainnet } = useNetwork() - const algokit = useAlgorandClient() - - const isOptedInToAsset = useCallback( - async (address: string, assetId: bigint): Promise => { - try { - const accountInfo = - await algokit.client.algod.accountInformation(address) - return (accountInfo.assets ?? []).some( - a => BigInt(a.assetId) === assetId, - ) - } catch { - return false - } - }, - [algokit], - ) - - const buildClaimAssetTxs = useCallback( - async (params: ClaimParams): Promise => { - const { sender, assetId, shouldClaimAlgo } = params - const arc59Config = isMainnet - ? config.arc59.mainnet - : config.arc59.testnet - - const suggestedParams = await algokit.getSuggestedParams() - - const appClient = new ARC59Client({ - appId: arc59Config.appId, - algorand: algokit, - defaultSender: sender, - }) - - const composer = algokit.newGroup() - const optedIn = await isOptedInToAsset(sender, assetId) - - // Calculate main call fee dynamically - // Base: 3 * minFee (claim itself + 2 inner txns) - let claimFee = - BigInt(BASE_CLAIM_TX_COUNT) * suggestedParams.minFee - if (shouldClaimAlgo) - claimFee += - BigInt(CLAIM_ALGO_TX_COUNT) * suggestedParams.minFee - if (!optedIn) claimFee += suggestedParams.minFee - - if (shouldClaimAlgo) { - composer.addAppCallMethodCall( - await appClient.params.arc59_claimAlgo({ - args: [], - staticFee: 0n.microAlgo(), - }), - ) - } - - if (!optedIn) { - composer.addAssetOptIn({ - sender, - assetId, - staticFee: 0n.microAlgo(), - }) - } - +export const useArc59ClaimTransaction = (): UseArc59ClaimTransactionResult => { + const { isMainnet } = useNetwork() + const algokit = useAlgorandClient() + + const isOptedInToAsset = useCallback( + async (address: string, assetId: bigint): Promise => { + try { + const accountInfo = + await algokit.client.algod.accountInformation(address) + return (accountInfo.assets ?? []).some( + a => BigInt(a.assetId) === assetId, + ) + } catch { + return false + } + }, + [algokit], + ) + + const buildClaimAssetTxs = useCallback( + async (params: ClaimParams): Promise => { + const { sender, assetId, shouldClaimAlgo } = params + const arc59Config = isMainnet + ? config.arc59.mainnet + : config.arc59.testnet + + const suggestedParams = await algokit.getSuggestedParams() + + const appClient = new ARC59Client({ + appId: arc59Config.appId, + algorand: algokit, + defaultSender: sender, + }) + + const composer = algokit.newGroup() + const optedIn = await isOptedInToAsset(sender, assetId) + + // Calculate main call fee dynamically + // Base: 3 * minFee (claim itself + 2 inner txns) + let claimFee = BigInt(BASE_CLAIM_TX_COUNT) * suggestedParams.minFee + if (shouldClaimAlgo) + claimFee += BigInt(CLAIM_ALGO_TX_COUNT) * suggestedParams.minFee + if (!optedIn) claimFee += suggestedParams.minFee + + if (shouldClaimAlgo) { composer.addAppCallMethodCall( - await appClient.params.arc59_claim({ - args: [assetId], - staticFee: claimFee.microAlgo(), + await appClient.params.arc59_claimAlgo({ + args: [], + staticFee: 0n.microAlgo(), }), ) + } - const { transactions } = await composer.build() - return transactions.map(t => t.txn) - }, - [algokit, isMainnet, isOptedInToAsset], - ) - - const buildRejectAssetTxs = useCallback( - async (params: RejectParams): Promise => { - const { sender, assetId, shouldClaimAlgo } = params - const arc59Config = isMainnet - ? config.arc59.mainnet - : config.arc59.testnet - - const suggestedParams = await algokit.getSuggestedParams() - - const appClient = new ARC59Client({ - appId: arc59Config.appId, - algorand: algokit, - defaultSender: sender, + if (!optedIn) { + composer.addAssetOptIn({ + sender, + assetId, + staticFee: 0n.microAlgo(), }) - - const composer = algokit.newGroup() - - // Calculate main call fee dynamically - // Base: 3 * minFee (reject itself + 2 inner txns) - let rejectFee = - BigInt(BASE_REJECT_TX_COUNT) * suggestedParams.minFee - if (shouldClaimAlgo) - rejectFee += - BigInt(CLAIM_ALGO_TX_COUNT) * suggestedParams.minFee - - if (shouldClaimAlgo) { - composer.addAppCallMethodCall( - await appClient.params.arc59_claimAlgo({ - args: [], - staticFee: 0n.microAlgo(), - }), - ) - } - + } + + composer.addAppCallMethodCall( + await appClient.params.arc59_claim({ + args: [assetId], + staticFee: claimFee.microAlgo(), + }), + ) + + const { transactions } = await composer.build() + return transactions.map(t => t.txn) + }, + [algokit, isMainnet, isOptedInToAsset], + ) + + const buildRejectAssetTxs = useCallback( + async (params: RejectParams): Promise => { + const { sender, assetId, shouldClaimAlgo } = params + const arc59Config = isMainnet + ? config.arc59.mainnet + : config.arc59.testnet + + const suggestedParams = await algokit.getSuggestedParams() + + const appClient = new ARC59Client({ + appId: arc59Config.appId, + algorand: algokit, + defaultSender: sender, + }) + + const composer = algokit.newGroup() + + // Calculate main call fee dynamically + // Base: 3 * minFee (reject itself + 2 inner txns) + let rejectFee = + BigInt(BASE_REJECT_TX_COUNT) * suggestedParams.minFee + if (shouldClaimAlgo) + rejectFee += + BigInt(CLAIM_ALGO_TX_COUNT) * suggestedParams.minFee + + if (shouldClaimAlgo) { composer.addAppCallMethodCall( - await appClient.params.arc59_reject({ - args: [assetId], - staticFee: rejectFee.microAlgo(), + await appClient.params.arc59_claimAlgo({ + args: [], + staticFee: 0n.microAlgo(), }), ) - - const { transactions } = await composer.build() - return transactions.map(t => t.txn) - }, - [algokit, isMainnet], - ) - - return { buildClaimAssetTxs, buildRejectAssetTxs } - } + } + + composer.addAppCallMethodCall( + await appClient.params.arc59_reject({ + args: [assetId], + staticFee: rejectFee.microAlgo(), + }), + ) + + const { transactions } = await composer.build() + return transactions.map(t => t.txn) + }, + [algokit, isMainnet], + ) + + return { buildClaimAssetTxs, buildRejectAssetTxs } +} diff --git a/packages/signing/src/hooks/__tests__/useSignAndSubmitGroup.spec.ts b/packages/signing/src/hooks/__tests__/useSignAndSubmitGroup.spec.ts index 6a28b55a1..d47e61ef4 100644 --- a/packages/signing/src/hooks/__tests__/useSignAndSubmitGroup.spec.ts +++ b/packages/signing/src/hooks/__tests__/useSignAndSubmitGroup.spec.ts @@ -34,7 +34,9 @@ vi.mock('../useSigningRequest', () => ({ vi.mock('@perawallet/wallet-core-blockchain', () => ({ useAlgorandClient: () => ({ client: { algod: {} } }), useTransactionEncoder: () => ({ - encodeSignedTransactions: vi.fn(arr => arr.map(() => new Uint8Array([1]))), + encodeSignedTransactions: vi.fn(arr => + arr.map(() => new Uint8Array([1])), + ), }), })) @@ -43,7 +45,9 @@ vi.mock('../../pipeline/submission/submitSignedTransactionGroup', () => ({ mockSubmitSignedTransactionGroup(...args), })) -const fakeTxn = { sender: { toString: () => 'A' } } as unknown as PeraTransaction +const fakeTxn = { + sender: { toString: () => 'A' }, +} as unknown as PeraTransaction describe('useSignAndSubmitGroup', () => { beforeEach(() => { diff --git a/packages/signing/src/hooks/useSignAndSubmitGroup.ts b/packages/signing/src/hooks/useSignAndSubmitGroup.ts index e5151f15f..5049ce7e3 100644 --- a/packages/signing/src/hooks/useSignAndSubmitGroup.ts +++ b/packages/signing/src/hooks/useSignAndSubmitGroup.ts @@ -51,9 +51,7 @@ export type SignAndSubmitGroupParams = { } export type SignAndSubmitGroupResult = { - submit: ( - params: SignAndSubmitGroupParams, - ) => Promise<{ txIds: string[] }> + submit: (params: SignAndSubmitGroupParams) => Promise<{ txIds: string[] }> } /** diff --git a/packages/transactions/src/hooks/__tests__/useAssetOptOutMutation.spec.ts b/packages/transactions/src/hooks/__tests__/useAssetOptOutMutation.spec.ts index f220afeea..b1d291119 100644 --- a/packages/transactions/src/hooks/__tests__/useAssetOptOutMutation.spec.ts +++ b/packages/transactions/src/hooks/__tests__/useAssetOptOutMutation.spec.ts @@ -48,7 +48,8 @@ vi.mock('@perawallet/wallet-core-assets', () => ({ })) vi.mock('@perawallet/wallet-core-accounts', () => ({ - deleteAssetHoldings: (...args: unknown[]) => mockDeleteAssetHoldings(...args), + deleteAssetHoldings: (...args: unknown[]) => + mockDeleteAssetHoldings(...args), useAccountBalancesInvalidator: () => ({ invalidate: mockInvalidate }), })) @@ -94,7 +95,10 @@ describe('useAssetOptOutMutation', () => { ) expect(mockSubmit).toHaveBeenCalledWith({ unsignedTxs: [{ sender: 'SENDER' }], - source: { name: 'asset-opt-out', description: 'Opt out of an asset' }, + source: { + name: 'asset-opt-out', + description: 'Opt out of an asset', + }, }) expect(mockDeleteAssetHoldings).toHaveBeenCalledWith({ accountAddress: 'SENDER', @@ -133,7 +137,10 @@ describe('useAssetOptOutMutation', () => { expect(mockAddAssetTransfer).toHaveBeenCalledTimes(2) expect(mockSubmit).toHaveBeenCalledWith({ unsignedTxs: [{ sender: 'SENDER' }, { sender: 'SENDER' }], - source: { name: 'asset-opt-out', description: 'Opt out of an asset' }, + source: { + name: 'asset-opt-out', + description: 'Opt out of an asset', + }, }) expect(mockDeleteAssetHoldings).toHaveBeenCalledWith({ accountAddress: 'SENDER', diff --git a/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx b/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx index 9cf791064..78e2d48c2 100644 --- a/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx +++ b/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx @@ -100,7 +100,10 @@ describe('useTransactionSendFlow', () => { expect(mockSubmit).toHaveBeenCalledWith( expect.objectContaining({ unsignedTxs: [TXN], - source: { name: 'send-transaction', description: 'Send transaction' }, + source: { + name: 'send-transaction', + description: 'Send transaction', + }, }), ) }) @@ -122,7 +125,10 @@ describe('useTransactionSendFlow', () => { expect(mockSubmit).toHaveBeenCalledWith( expect.objectContaining({ unsignedTxs: [TXN], - source: { name: 'send-transaction', description: 'Send transaction' }, + source: { + name: 'send-transaction', + description: 'Send transaction', + }, }), ) }) @@ -150,7 +156,10 @@ describe('useTransactionSendFlow', () => { expect(mockSubmit).toHaveBeenCalledWith( expect.objectContaining({ unsignedTxs: [TXN], - source: { name: 'send-transaction', description: 'Send transaction' }, + source: { + name: 'send-transaction', + description: 'Send transaction', + }, }), ) }) @@ -178,7 +187,10 @@ describe('useTransactionSendFlow', () => { expect(mockSubmit).toHaveBeenCalledWith( expect.objectContaining({ unsignedTxs: [TXN], - source: { name: 'send-transaction', description: 'Send transaction' }, + source: { + name: 'send-transaction', + description: 'Send transaction', + }, }), ) }) @@ -199,7 +211,10 @@ describe('useTransactionSendFlow', () => { expect(mockSubmit).toHaveBeenCalledWith( expect.objectContaining({ unsignedTxs: [TXN], - source: { name: 'send-transaction', description: 'Send transaction' }, + source: { + name: 'send-transaction', + description: 'Send transaction', + }, }), ) }) @@ -220,7 +235,10 @@ describe('useTransactionSendFlow', () => { expect(mockSubmit).toHaveBeenCalledWith( expect.objectContaining({ unsignedTxs: [TXN], - source: { name: 'send-transaction', description: 'Send transaction' }, + source: { + name: 'send-transaction', + description: 'Send transaction', + }, }), ) }) diff --git a/packages/transactions/src/hooks/useAssetOptInMutation.ts b/packages/transactions/src/hooks/useAssetOptInMutation.ts index 8241f4293..bf5b9a7b4 100644 --- a/packages/transactions/src/hooks/useAssetOptInMutation.ts +++ b/packages/transactions/src/hooks/useAssetOptInMutation.ts @@ -120,5 +120,8 @@ export const useAssetOptInMutation = (): UseAssetOptInMutationResult => { } } -export { AlreadyOptedInError, InsufficientBalanceForOptInError } from '../errors' +export { + AlreadyOptedInError, + InsufficientBalanceForOptInError, +} from '../errors' export type { AssetOptInParams, UseAssetOptInMutationResult } diff --git a/packages/transactions/src/hooks/useTransactionSendFlow.ts b/packages/transactions/src/hooks/useTransactionSendFlow.ts index 14ef077b8..e7b0456e1 100644 --- a/packages/transactions/src/hooks/useTransactionSendFlow.ts +++ b/packages/transactions/src/hooks/useTransactionSendFlow.ts @@ -127,7 +127,10 @@ export const useTransactionSendFlow = () => { const assetDecimals = params.asset?.decimals ?? 0 const amountInBaseUnits = BigInt( - displayUnitsToBaseUnits(params.amount, assetDecimals).toString(), + displayUnitsToBaseUnits( + params.amount, + assetDecimals, + ).toString(), ) const composer = algokit.newGroup() @@ -171,7 +174,10 @@ export const useTransactionSendFlow = () => { const assetDecimals = params.asset?.decimals ?? 0 const amountInBaseUnits = BigInt( - displayUnitsToBaseUnits(params.amount, assetDecimals).toString(), + displayUnitsToBaseUnits( + params.amount, + assetDecimals, + ).toString(), ) const assetId = BigInt(params.asset.assetId) @@ -216,12 +222,7 @@ export const useTransactionSendFlow = () => { } } }, - [ - buildExpressTxs, - buildSendViaInboxTxs, - buildNormalTxs, - submit, - ], + [buildExpressTxs, buildSendViaInboxTxs, buildNormalTxs, submit], ) const executeArc59 = useCallback( From adb95e2d5836db36353c2710ecc21307eaa254a4 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:55:13 +0100 Subject: [PATCH 11/14] fix(transactions): restore empty-asset guard, type send flow, tighten opt-in cancel coverage [PERA-3966] --- .../__tests__/useAssetOptInMutation.spec.ts | 35 ++++++++++++++++--- .../__tests__/useTransactionSendFlow.spec.tsx | 23 +++++++++++- .../src/hooks/useTransactionSendFlow.ts | 11 +++++- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/packages/transactions/src/hooks/__tests__/useAssetOptInMutation.spec.ts b/packages/transactions/src/hooks/__tests__/useAssetOptInMutation.spec.ts index 73d8ded7f..9c4b7691f 100644 --- a/packages/transactions/src/hooks/__tests__/useAssetOptInMutation.spec.ts +++ b/packages/transactions/src/hooks/__tests__/useAssetOptInMutation.spec.ts @@ -26,20 +26,22 @@ const mockNewGroup = vi.fn(() => ({ addAssetOptIn: vi.fn().mockReturnThis(), build: mockBuild, })) +const mockInsertAssetHolding = vi.fn().mockResolvedValue(undefined) +const mockFetchAndPersistAssets = vi.fn().mockResolvedValue(undefined) +const mockInvalidate = vi.fn() vi.mock('@perawallet/wallet-core-signing', () => ({ useSignAndSubmitGroup: () => ({ submit: mockSubmit }), })) vi.mock('@perawallet/wallet-core-accounts', () => ({ - insertAssetHolding: vi.fn().mockResolvedValue(undefined), - useAccountBalancesInvalidator: () => ({ - invalidate: vi.fn(), - }), + insertAssetHolding: (...args: unknown[]) => mockInsertAssetHolding(...args), + useAccountBalancesInvalidator: () => ({ invalidate: mockInvalidate }), })) vi.mock('@perawallet/wallet-core-assets', () => ({ - fetchAndPersistAssets: vi.fn().mockResolvedValue(undefined), + fetchAndPersistAssets: (...args: unknown[]) => + mockFetchAndPersistAssets(...args), })) vi.mock('@perawallet/wallet-core-blockchain', () => ({ @@ -86,6 +88,16 @@ describe('useAssetOptInMutation', () => { unsignedTxs: [{ sender: 'SENDER' }], }), ) + expect(mockInsertAssetHolding).toHaveBeenCalledWith({ + accountAddress: 'SENDER', + assetId: '12345', + network: 'testnet', + }) + expect(mockFetchAndPersistAssets).toHaveBeenCalledWith( + ['12345'], + 'testnet', + ) + expect(mockInvalidate).toHaveBeenCalledTimes(1) }) it('throws AlreadyOptedInError without calling the pipeline', async () => { @@ -123,4 +135,17 @@ describe('useAssetOptInMutation', () => { expect(mockSubmit).not.toHaveBeenCalled() }) + + it('does not run post-submit work when submit fails', async () => { + mockSubmit.mockRejectedValueOnce(new Error('user cancelled')) + const { result } = renderHook(() => useAssetOptInMutation()) + await act(async () => { + await expect( + result.current.optIn({ sender: 'SENDER', assetId: 12345n }), + ).rejects.toThrow('user cancelled') + }) + expect(mockInsertAssetHolding).not.toHaveBeenCalled() + expect(mockFetchAndPersistAssets).not.toHaveBeenCalled() + expect(mockInvalidate).not.toHaveBeenCalled() + }) }) diff --git a/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx b/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx index 78e2d48c2..66f5b24f4 100644 --- a/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx +++ b/packages/transactions/src/hooks/__tests__/useTransactionSendFlow.spec.tsx @@ -13,7 +13,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { Decimal } from 'decimal.js' -import { useTransactionSendFlow } from '../useTransactionSendFlow' +import { + useTransactionSendFlow, + InvalidSendParamsError, +} from '../useTransactionSendFlow' const mockSubmit = vi.fn() const mockBuildSendViaInbox = vi.fn() @@ -242,4 +245,22 @@ describe('useTransactionSendFlow', () => { }), ) }) + + it('throws InvalidSendParamsError for an empty assetId string', async () => { + const { result } = renderHook(() => useTransactionSendFlow()) + await act(async () => { + await expect( + result.current.execute({ + params: { + sendMode: 'normal', + sender: { address: 'A' } as any, + receiver: 'B', + asset: { assetId: '', decimals: 6 } as any, + amount: new Decimal(1), + }, + }), + ).rejects.toBeInstanceOf(InvalidSendParamsError) + }) + expect(mockSubmit).not.toHaveBeenCalled() + }) }) diff --git a/packages/transactions/src/hooks/useTransactionSendFlow.ts b/packages/transactions/src/hooks/useTransactionSendFlow.ts index e7b0456e1..bbb1c9d4d 100644 --- a/packages/transactions/src/hooks/useTransactionSendFlow.ts +++ b/packages/transactions/src/hooks/useTransactionSendFlow.ts @@ -63,7 +63,11 @@ const SEND_SOURCE = { description: 'Send transaction', } -export const useTransactionSendFlow = () => { +type UseTransactionSendFlowResult = { + execute: (args: UseTransactionSendFlowParams) => Promise +} + +export const useTransactionSendFlow = (): UseTransactionSendFlowResult => { const algokit = useAlgorandClient() const { submit } = useSignAndSubmitGroup() const { buildSendViaInboxTxs } = useArc59SendTransaction() @@ -118,6 +122,7 @@ export const useTransactionSendFlow = () => { async (params: SendTransactionParams): Promise => { if ( !params.asset || + params.asset.assetId === '' || !params.sender || !params.receiver || params.amount === undefined @@ -165,6 +170,7 @@ export const useTransactionSendFlow = () => { async (params: SendTransactionParams): Promise => { if ( !params.asset || + params.asset.assetId === '' || !params.sender || !params.receiver || params.amount === undefined @@ -277,8 +283,11 @@ export const useTransactionSendFlow = () => { return { execute } } +export { InvalidSendParamsError } from '../errors' + export type { SendTransactionParams, SendClaimParams, UseTransactionSendFlowParams, + UseTransactionSendFlowResult, } From 12190713496633575badf4db0b1fb1fa5b8e386a Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:17:38 +0100 Subject: [PATCH 12/14] chore(transactions): add exhaustiveness guard to send-flow switch [PERA-3966] --- packages/transactions/src/hooks/useTransactionSendFlow.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/transactions/src/hooks/useTransactionSendFlow.ts b/packages/transactions/src/hooks/useTransactionSendFlow.ts index bbb1c9d4d..b574741ff 100644 --- a/packages/transactions/src/hooks/useTransactionSendFlow.ts +++ b/packages/transactions/src/hooks/useTransactionSendFlow.ts @@ -226,6 +226,10 @@ export const useTransactionSendFlow = (): UseTransactionSendFlowResult => { }) return result.txIds[0] } + default: { + params.sendMode satisfies never + throw new InvalidSendParamsError() + } } }, [buildExpressTxs, buildSendViaInboxTxs, buildNormalTxs, submit], From b1bc85db192dae603c5ba733206766db5770685a Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:17:48 +0100 Subject: [PATCH 13/14] feat(mobile): suppress cancel toasts on user-rejected signing [PERA-3966] --- .../__tests__/useAccountAssetList.spec.ts | 176 ++++++++++++++++++ .../AccountAssetList/useAccountAssetList.ts | 5 + .../__tests__/useRemoveAssetsScreen.spec.ts | 17 ++ .../useRemoveAssetsScreen.ts | 5 + .../__tests__/useAddAssetScreen.spec.ts | 145 +++++++++++++++ .../AddAssetScreen/useAddAssetScreen.ts | 5 + .../__tests__/useCollectibleDetail.spec.ts | 17 ++ .../useCollectibleDetail.ts | 5 + .../useClaimProcessingScreen.spec.ts | 149 +++++++++++++++ .../useClaimProcessingScreen.ts | 6 + .../useTransactionProcessingScreen.spec.ts | 141 ++++++++++++++ .../useTransactionProcessingScreen.ts | 6 + 12 files changed, 677 insertions(+) create mode 100644 apps/mobile/src/modules/accounts/components/AccountAssetList/__tests__/useAccountAssetList.spec.ts create mode 100644 apps/mobile/src/modules/assets/screens/AddAssetScreen/__tests__/useAddAssetScreen.spec.ts create mode 100644 apps/mobile/src/modules/transactions/screens/claim-assets/ClaimProcessingScreen/__tests__/useClaimProcessingScreen.spec.ts create mode 100644 apps/mobile/src/modules/transactions/screens/send-funds/TransactionProcessingScreen/__tests__/useTransactionProcessingScreen.spec.ts diff --git a/apps/mobile/src/modules/accounts/components/AccountAssetList/__tests__/useAccountAssetList.spec.ts b/apps/mobile/src/modules/accounts/components/AccountAssetList/__tests__/useAccountAssetList.spec.ts new file mode 100644 index 000000000..a9a729c54 --- /dev/null +++ b/apps/mobile/src/modules/accounts/components/AccountAssetList/__tests__/useAccountAssetList.spec.ts @@ -0,0 +1,176 @@ +/* + 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 { renderHook, act } from '@test-utils/render' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Decimal } from 'decimal.js' +import { useAccountAssetList } from '../useAccountAssetList' +import { UserRejectedSigningError } from '@perawallet/wallet-core-signing' +import type { WalletAccount } from '@perawallet/wallet-core-accounts' + +const mockAccount = { + address: 'test-address', + name: 'Test Account', +} as WalletAccount + +const mockT = (key: string) => key + +vi.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: vi.fn() }), +})) + +vi.mock('@react-navigation/native-stack', () => ({})) + +vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { + const actual = + await importOriginal< + typeof import('@perawallet/wallet-core-accounts') + >() + return { + ...actual, + useAccountBalancesQuery: vi.fn(() => ({ + accountBalances: new Map([ + [ + 'test-address', + { + assetBalances: [ + { + assetId: '123', + amount: new Decimal(0), + algoValue: new Decimal(0), + }, + ], + }, + ], + ]), + isPending: false, + })), + useAccountLogicalType: vi.fn(() => 'Algo25'), + isSigningLogicalType: vi.fn(() => true), + useSortedAssetBalances: vi.fn(() => ({ + sortedBalances: [], + assetSortMode: 'balanceDesc', + })), + } +}) + +const { mockOptOut } = vi.hoisted(() => ({ + mockOptOut: vi.fn().mockResolvedValue({ txIds: ['tx1'] }), +})) + +vi.mock('@perawallet/wallet-core-transactions', () => ({ + useAssetOptOutMutation: () => ({ + optOut: mockOptOut, + isLoading: false, + }), +})) + +const { mockShowToast } = vi.hoisted(() => ({ + mockShowToast: vi.fn(), +})) + +vi.mock('@hooks/useToast', () => ({ + useToast: () => ({ showToast: mockShowToast }), +})) + +vi.mock('@hooks/useAlgodErrorMessage', () => ({ + useAlgodErrorMessage: () => ({ + getMessage: (_err: unknown) => ({ title: 'Error', body: 'error body' }), + }), +})) + +vi.mock('@perawallet/wallet-core-assets', async importOriginal => { + const actual = + await importOriginal() + return { + ...actual, + useAssetsQuery: vi.fn(() => ({ data: new Map() })), + useAssetPricesQuery: vi.fn(() => ({ data: new Map() })), + useAssetPreferencesStore: vi.fn( + (selector: (state: unknown) => unknown) => + selector({ + hideZeroBalance: false, + displayNfts: true, + displayOptedInNfts: true, + }), + ), + isCollectible: vi.fn(() => false), + } +}) + +vi.mock('@perawallet/wallet-core-search', () => ({ + useGlobalSearch: vi.fn(() => ({ + value: '', + setValue: vi.fn(), + results: { assets: [] }, + isLoading: false, + })), +})) + +vi.mock('@constants/ui', () => ({ + SEARCH_DEBOUNCE_TIME_SHORT: 150, +})) + +describe('useAccountAssetList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not show an error toast when user cancels the signing overlay', async () => { + mockOptOut.mockRejectedValueOnce(new UserRejectedSigningError()) + + const { result } = renderHook(() => + useAccountAssetList({ account: mockAccount, t: mockT }), + ) + + // Set up an asset for opt-out and confirm + act(() => { + result.current.handleOptOut({ + assetId: '123', + amount: new Decimal(0), + algoValue: new Decimal(0), + }) + }) + + await act(async () => { + await result.current.handleConfirmOptOut() + }) + + expect(mockShowToast).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('shows an error toast when opt-out fails with a non-cancel error', async () => { + mockOptOut.mockRejectedValueOnce(new Error('Network error')) + + const { result } = renderHook(() => + useAccountAssetList({ account: mockAccount, t: mockT }), + ) + + act(() => { + result.current.handleOptOut({ + assetId: '123', + amount: new Decimal(0), + algoValue: new Decimal(0), + }) + }) + + await act(async () => { + await result.current.handleConfirmOptOut() + }) + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) +}) diff --git a/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts b/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts index 4b210fcb4..a2ecc2686 100644 --- a/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts +++ b/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts @@ -30,6 +30,7 @@ import { type AssetSortMode, } from '@perawallet/wallet-core-assets' import { useGlobalSearch } from '@perawallet/wallet-core-search' +import { UserRejectedSigningError } from '@perawallet/wallet-core-signing' import { useAssetOptOutMutation } from '@perawallet/wallet-core-transactions' import { useAlgodErrorMessage } from '@hooks/useAlgodErrorMessage' import { useModalState, ModalState } from '@hooks/useModalState' @@ -206,6 +207,10 @@ export const useAccountAssetList = ({ type: 'success', }) } catch (err) { + if (err instanceof UserRejectedSigningError) { + // User dismissed the LedgerSigningOverlay — overlay already went away; no toast. + return + } showToast({ title: t('asset_opt_out.error'), body: getMessage(err).body, diff --git a/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/__tests__/useRemoveAssetsScreen.spec.ts b/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/__tests__/useRemoveAssetsScreen.spec.ts index 4fad7189e..a445d4198 100644 --- a/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/__tests__/useRemoveAssetsScreen.spec.ts +++ b/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/__tests__/useRemoveAssetsScreen.spec.ts @@ -15,6 +15,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { Decimal } from 'decimal.js' import { useRemoveAssetsScreen } from '../useRemoveAssetsScreen' import type { Nullable } from '@perawallet/wallet-core-shared' +import { UserRejectedSigningError } from '@perawallet/wallet-core-signing' const mockAccount = { address: 'test-address', name: 'Test' } @@ -282,4 +283,20 @@ describe('useRemoveAssetsScreen', () => { expect(result.current.removeError?.message).toBe('Rate limited') }) + + it('does not set removeError when user cancels the signing overlay', async () => { + mockOptOut.mockRejectedValueOnce(new UserRejectedSigningError()) + + const { result } = renderHook(() => useRemoveAssetsScreen()) + + act(() => { + result.current.handleToggleSelect('123') + }) + + await act(async () => { + await result.current.handleRemoveSelected() + }) + + expect(result.current.removeError).toBeNull() + }) }) diff --git a/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/useRemoveAssetsScreen.ts b/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/useRemoveAssetsScreen.ts index d4eece0f4..d4961b366 100644 --- a/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/useRemoveAssetsScreen.ts +++ b/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/useRemoveAssetsScreen.ts @@ -21,6 +21,7 @@ import { useAssetsQuery, PeraAsset, } from '@perawallet/wallet-core-assets' +import { UserRejectedSigningError } from '@perawallet/wallet-core-signing' import { useAssetOptOutMutation } from '@perawallet/wallet-core-transactions' import { useLanguage } from '@hooks/useLanguage' import type { Nullable } from '@perawallet/wallet-core-shared' @@ -145,6 +146,10 @@ export const useRemoveAssetsScreen = (): UseRemoveAssetsScreenResult => { await optOut(optOutParams) setSelectedAssetIds(new Set()) } catch (err) { + if (err instanceof UserRejectedSigningError) { + // User dismissed the LedgerSigningOverlay — overlay already went away; no toast. + return + } setRemoveError(err instanceof Error ? err : new Error(String(err))) } }, [selectedAccount, selectedAssetIds, assets, optOut]) diff --git a/apps/mobile/src/modules/assets/screens/AddAssetScreen/__tests__/useAddAssetScreen.spec.ts b/apps/mobile/src/modules/assets/screens/AddAssetScreen/__tests__/useAddAssetScreen.spec.ts new file mode 100644 index 000000000..7b83f2cc2 --- /dev/null +++ b/apps/mobile/src/modules/assets/screens/AddAssetScreen/__tests__/useAddAssetScreen.spec.ts @@ -0,0 +1,145 @@ +/* + 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 { renderHook, act } from '@test-utils/render' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Decimal } from 'decimal.js' +import { useAddAssetScreen } from '../useAddAssetScreen' +import { UserRejectedSigningError } from '@perawallet/wallet-core-signing' + +const mockAccount = { address: 'test-address', name: 'Test Account' } + +const { mockGetSelectedAccount } = vi.hoisted(() => ({ + mockGetSelectedAccount: vi.fn(() => mockAccount), +})) + +vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { + const actual = + await importOriginal< + typeof import('@perawallet/wallet-core-accounts') + >() + return { + ...actual, + useAccountsStore: vi.fn((selector: (state: unknown) => unknown) => + selector({ getSelectedAccount: mockGetSelectedAccount }), + ), + useAccountBalancesQuery: vi.fn(() => ({ + accountBalances: new Map([ + [ + 'test-address', + { + assetBalances: [ + { + assetId: '123', + amount: new Decimal(1), + algoValue: new Decimal(1), + }, + ], + }, + ], + ]), + })), + } +}) + +const { mockOptIn } = vi.hoisted(() => ({ + mockOptIn: vi.fn().mockResolvedValue({ txIds: ['tx1'] }), +})) + +vi.mock('@perawallet/wallet-core-transactions', () => ({ + useAssetOptInMutation: () => ({ + optIn: mockOptIn, + isLoading: false, + }), +})) + +const { mockShowToast } = vi.hoisted(() => ({ + mockShowToast: vi.fn(), +})) + +vi.mock('@hooks/useToast', () => ({ + useToast: () => ({ showToast: mockShowToast }), +})) + +vi.mock('@hooks/useAlgodErrorMessage', () => ({ + useAlgodErrorMessage: () => ({ + getMessage: (_err: unknown) => ({ title: 'Error', body: 'error body' }), + }), +})) + +vi.mock('@hooks/useLanguage', () => ({ + useLanguage: () => ({ t: (key: string) => key }), +})) + +vi.mock('@perawallet/wallet-core-search', () => ({ + useGlobalSearch: vi.fn(() => ({ + value: '', + setValue: vi.fn(), + results: { + remoteAssets: [ + { assetId: '999', name: 'Test Asset', unitName: 'TST' }, + ], + }, + isLoading: false, + hasNextRemotePage: false, + isFetchingNextRemotePage: false, + fetchNextRemotePage: vi.fn(), + })), +})) + +vi.mock('@constants/ui', () => ({ + SEARCH_DEBOUNCE_TIME: 300, +})) + +describe('useAddAssetScreen', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSelectedAccount.mockReturnValue(mockAccount) + }) + + it('does not show an error toast when user cancels the signing overlay', async () => { + mockOptIn.mockRejectedValueOnce(new UserRejectedSigningError()) + + const { result } = renderHook(() => useAddAssetScreen()) + + // Request and confirm opt-in for an asset + act(() => { + result.current.handleRequestAdd('999') + }) + + await act(async () => { + await result.current.handleConfirmAdd() + }) + + expect(mockShowToast).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('shows an error toast when opt-in fails with a non-cancel error', async () => { + mockOptIn.mockRejectedValueOnce(new Error('Network error')) + + const { result } = renderHook(() => useAddAssetScreen()) + + act(() => { + result.current.handleRequestAdd('999') + }) + + await act(async () => { + await result.current.handleConfirmAdd() + }) + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) +}) diff --git a/apps/mobile/src/modules/assets/screens/AddAssetScreen/useAddAssetScreen.ts b/apps/mobile/src/modules/assets/screens/AddAssetScreen/useAddAssetScreen.ts index 78e772624..8d5323808 100644 --- a/apps/mobile/src/modules/assets/screens/AddAssetScreen/useAddAssetScreen.ts +++ b/apps/mobile/src/modules/assets/screens/AddAssetScreen/useAddAssetScreen.ts @@ -17,6 +17,7 @@ import { } from '@perawallet/wallet-core-accounts' import type { AssetSearchItem } from '@perawallet/wallet-core-assets' import { useGlobalSearch } from '@perawallet/wallet-core-search' +import { UserRejectedSigningError } from '@perawallet/wallet-core-signing' import { useAssetOptInMutation } from '@perawallet/wallet-core-transactions' import { useAlgodErrorMessage } from '@hooks/useAlgodErrorMessage' import { useLanguage } from '@hooks/useLanguage' @@ -154,6 +155,10 @@ export const useAddAssetScreen = ( type: 'success', }) } catch (err) { + if (err instanceof UserRejectedSigningError) { + // User dismissed the LedgerSigningOverlay — overlay already went away; no toast. + return + } showToast({ title: t('add_asset.opt_in.failed_title'), body: getMessage(err).body, diff --git a/apps/mobile/src/modules/assets/screens/CollectibleDetailScreen/__tests__/useCollectibleDetail.spec.ts b/apps/mobile/src/modules/assets/screens/CollectibleDetailScreen/__tests__/useCollectibleDetail.spec.ts index 3a0723e93..8f85461ab 100644 --- a/apps/mobile/src/modules/assets/screens/CollectibleDetailScreen/__tests__/useCollectibleDetail.spec.ts +++ b/apps/mobile/src/modules/assets/screens/CollectibleDetailScreen/__tests__/useCollectibleDetail.spec.ts @@ -15,6 +15,7 @@ import { renderHook } from '@testing-library/react' import { Decimal } from 'decimal.js' import { useCollectibleDetail } from '../useCollectibleDetail' import type { PeraAsset } from '@perawallet/wallet-core-assets' +import { UserRejectedSigningError } from '@perawallet/wallet-core-signing' const mockCopyToClipboard = vi.fn() const mockShowToast = vi.fn() @@ -283,6 +284,22 @@ describe('useCollectibleDetail', () => { expect(mockGoBack).not.toHaveBeenCalled() }) + it('does not show an error toast when user cancels the signing overlay', async () => { + mockUseAccountAssetBalanceQuery.mockReturnValue({ + data: { amount: new Decimal(0), algoValue: new Decimal(0) }, + }) + mockOptOut.mockRejectedValueOnce(new UserRejectedSigningError()) + + const { result } = renderHook(() => useCollectibleDetail('12345')) + + await result.current.handleConfirmOptOut() + + expect(mockShowToast).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + expect(mockGoBack).not.toHaveBeenCalled() + }) + it('returns empty traits and media when collectible is undefined', () => { mockUseSingleAssetDetailsQuery.mockReturnValue({ data: { diff --git a/apps/mobile/src/modules/assets/screens/CollectibleDetailScreen/useCollectibleDetail.ts b/apps/mobile/src/modules/assets/screens/CollectibleDetailScreen/useCollectibleDetail.ts index c5e1ebcd0..e9e09842d 100644 --- a/apps/mobile/src/modules/assets/screens/CollectibleDetailScreen/useCollectibleDetail.ts +++ b/apps/mobile/src/modules/assets/screens/CollectibleDetailScreen/useCollectibleDetail.ts @@ -27,6 +27,7 @@ import { isSigningLogicalType, type AssetWithAccountBalance, } from '@perawallet/wallet-core-accounts' +import { UserRejectedSigningError } from '@perawallet/wallet-core-signing' import { useAssetOptOutMutation } from '@perawallet/wallet-core-transactions' import { useAlgodErrorMessage } from '@hooks/useAlgodErrorMessage' import { useToast } from '@hooks/useToast' @@ -156,6 +157,10 @@ export const useCollectibleDetail = ( navigation.goBack() } } catch (err) { + if (err instanceof UserRejectedSigningError) { + // User dismissed the LedgerSigningOverlay — overlay already went away; no toast. + return + } optOutModal.close() showToast({ title: t('asset_opt_out.error'), diff --git a/apps/mobile/src/modules/transactions/screens/claim-assets/ClaimProcessingScreen/__tests__/useClaimProcessingScreen.spec.ts b/apps/mobile/src/modules/transactions/screens/claim-assets/ClaimProcessingScreen/__tests__/useClaimProcessingScreen.spec.ts new file mode 100644 index 000000000..6552ae4bb --- /dev/null +++ b/apps/mobile/src/modules/transactions/screens/claim-assets/ClaimProcessingScreen/__tests__/useClaimProcessingScreen.spec.ts @@ -0,0 +1,149 @@ +/* + 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 { renderHook } from '@test-utils/render' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useClaimProcessingScreen } from '../useClaimProcessingScreen' +import { UserRejectedSigningError } from '@perawallet/wallet-core-signing' +import { Decimal } from 'decimal.js' + +const mockGoBack = vi.fn() +const mockReplace = vi.fn() + +vi.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + goBack: mockGoBack, + replace: mockReplace, + }), + useRoute: () => ({ + params: { + mode: 'claimArc59' as const, + assetIndex: 0, + shouldClaimAlgo: false, + }, + }), +})) + +vi.mock('@react-navigation/native-stack', () => ({})) + +const { mockExecute } = vi.hoisted(() => ({ + mockExecute: vi.fn(), +})) + +vi.mock('@perawallet/wallet-core-transactions', () => ({ + useTransactionSendFlow: () => ({ + execute: mockExecute, + }), +})) + +const { mockShowToast } = vi.hoisted(() => ({ + mockShowToast: vi.fn(), +})) + +vi.mock('@hooks/useToast', () => ({ + useToast: () => ({ showToast: mockShowToast }), +})) + +vi.mock('@hooks/useAlgodErrorMessage', () => ({ + useAlgodErrorMessage: () => ({ + getMessage: (_err: unknown) => ({ title: 'Error', body: 'error body' }), + }), +})) + +vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { + const actual = + await importOriginal< + typeof import('@perawallet/wallet-core-accounts') + >() + return { + ...actual, + useFindAccountByAddress: vi.fn(() => ({ + address: 'test-address', + name: 'Test', + })), + useAccountBalancesInvalidator: vi.fn(() => ({ invalidate: vi.fn() })), + } +}) + +vi.mock('@modules/transactions/hooks', () => ({ + useClaimAssets: () => ({ + assetRequests: [ + { + asset: { + assetId: '123', + name: 'Test Asset', + decimals: 0, + totalSupply: new Decimal(1), + creator: { address: 'CREATOR' }, + }, + }, + ], + accountAddress: 'test-address', + setOnFinished: vi.fn(), + }), +})) + +vi.mock('@components/core', () => ({ + bottomSheetNotifier: { current: null }, +})) + +vi.mock('@perawallet/wallet-core-asa-inbox', () => ({ + useArc59Invalidator: () => ({ remove: vi.fn() }), +})) + +vi.mock('@perawallet/wallet-core-messages', () => ({ + useInboxInvalidator: () => ({ invalidate: vi.fn() }), +})) + +vi.mock('@hooks/useAppNavigation', () => ({ + useAppNavigation: () => ({ replace: vi.fn() }), +})) + +vi.mock('react-native', () => ({ + BackHandler: { + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + }, +})) + +describe('useClaimProcessingScreen', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('calls navigation.goBack and does not show an error toast when user cancels the signing overlay', async () => { + mockExecute.mockRejectedValueOnce(new UserRejectedSigningError()) + + renderHook(() => useClaimProcessingScreen()) + + // Allow microtasks to flush + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(mockGoBack).toHaveBeenCalled() + expect(mockShowToast).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('shows an error toast and navigates back when execution fails with a non-cancel error', async () => { + mockExecute.mockRejectedValueOnce(new Error('Network error')) + + renderHook(() => useClaimProcessingScreen()) + + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + expect.anything(), + ) + expect(mockGoBack).toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/src/modules/transactions/screens/claim-assets/ClaimProcessingScreen/useClaimProcessingScreen.ts b/apps/mobile/src/modules/transactions/screens/claim-assets/ClaimProcessingScreen/useClaimProcessingScreen.ts index c93de8710..33f71ad1c 100644 --- a/apps/mobile/src/modules/transactions/screens/claim-assets/ClaimProcessingScreen/useClaimProcessingScreen.ts +++ b/apps/mobile/src/modules/transactions/screens/claim-assets/ClaimProcessingScreen/useClaimProcessingScreen.ts @@ -25,6 +25,7 @@ import { SendClaimParams, useTransactionSendFlow, } from '@perawallet/wallet-core-transactions' +import { UserRejectedSigningError } from '@perawallet/wallet-core-signing' import type { MessagesStackParamList } from '@modules/messages/routes/types' import { useAlgodErrorMessage } from '@hooks/useAlgodErrorMessage' import { @@ -86,6 +87,11 @@ export const useClaimProcessingScreen = () => { }) }) .catch(error => { + if (error instanceof UserRejectedSigningError) { + // Silent navigation back — user already saw the overlay's cancel button. + navigation.goBack() + return + } const { title, body } = getMessage(error) showToast( { diff --git a/apps/mobile/src/modules/transactions/screens/send-funds/TransactionProcessingScreen/__tests__/useTransactionProcessingScreen.spec.ts b/apps/mobile/src/modules/transactions/screens/send-funds/TransactionProcessingScreen/__tests__/useTransactionProcessingScreen.spec.ts new file mode 100644 index 000000000..59931ee8b --- /dev/null +++ b/apps/mobile/src/modules/transactions/screens/send-funds/TransactionProcessingScreen/__tests__/useTransactionProcessingScreen.spec.ts @@ -0,0 +1,141 @@ +/* + 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 { renderHook } from '@test-utils/render' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useTransactionProcessingScreen } from '../useTransactionProcessingScreen' +import { UserRejectedSigningError } from '@perawallet/wallet-core-signing' + +const mockGoBack = vi.fn() +const mockReplace = vi.fn() + +vi.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + goBack: mockGoBack, + replace: mockReplace, + }), +})) + +vi.mock('@react-navigation/stack', () => ({})) + +const { mockExecute } = vi.hoisted(() => ({ + mockExecute: vi.fn(), +})) + +vi.mock('@perawallet/wallet-core-transactions', () => ({ + useTransactionSendFlow: () => ({ + execute: mockExecute, + }), +})) + +const { mockShowToast } = vi.hoisted(() => ({ + mockShowToast: vi.fn(), +})) + +vi.mock('@hooks/useToast', () => ({ + useToast: () => ({ showToast: mockShowToast }), +})) + +vi.mock('@hooks/useAlgodErrorMessage', () => ({ + useAlgodErrorMessage: () => ({ + getMessage: (_err: unknown) => ({ title: 'Error', body: 'error body' }), + }), +})) + +vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { + const actual = + await importOriginal< + typeof import('@perawallet/wallet-core-accounts') + >() + return { + ...actual, + useSelectedAccount: vi.fn(() => ({ + address: 'test-address', + name: 'Test', + })), + useAccountBalancesInvalidator: vi.fn(() => ({ invalidate: vi.fn() })), + } +}) + +vi.mock('@perawallet/wallet-core-assets', async importOriginal => { + const actual = + await importOriginal() + return { + ...actual, + useAssetsQuery: vi.fn(() => ({ data: new Map() })), + } +}) + +vi.mock('@modules/transactions/hooks', () => ({ + useSendFunds: () => ({ + selectedAssetId: '0', + amount: undefined, + destination: 'dest-address', + note: undefined, + sendMode: 'normal' as const, + arc59Summary: undefined, + isCloseAccount: false, + }), +})) + +vi.mock('@components/core', () => ({ + bottomSheetNotifier: { current: null }, +})) + +vi.mock('@perawallet/wallet-core-shared', async importOriginal => { + const actual = + await importOriginal() + return { + ...actual, + logger: { error: vi.fn() }, + } +}) + +vi.mock('react-native', () => ({ + BackHandler: { + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + }, +})) + +describe('useTransactionProcessingScreen', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('calls navigation.goBack and does not show an error toast when user cancels the signing overlay', async () => { + mockExecute.mockRejectedValueOnce(new UserRejectedSigningError()) + + renderHook(() => useTransactionProcessingScreen()) + + // Allow microtasks to flush + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(mockGoBack).toHaveBeenCalled() + expect(mockShowToast).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('shows an error toast and navigates back when execution fails with a non-cancel error', async () => { + mockExecute.mockRejectedValueOnce(new Error('Network error')) + + renderHook(() => useTransactionProcessingScreen()) + + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + expect.anything(), + ) + expect(mockGoBack).toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/src/modules/transactions/screens/send-funds/TransactionProcessingScreen/useTransactionProcessingScreen.ts b/apps/mobile/src/modules/transactions/screens/send-funds/TransactionProcessingScreen/useTransactionProcessingScreen.ts index 0794d220a..7c3e8100a 100644 --- a/apps/mobile/src/modules/transactions/screens/send-funds/TransactionProcessingScreen/useTransactionProcessingScreen.ts +++ b/apps/mobile/src/modules/transactions/screens/send-funds/TransactionProcessingScreen/useTransactionProcessingScreen.ts @@ -25,6 +25,7 @@ import { SendTransactionParams, useTransactionSendFlow, } from '@perawallet/wallet-core-transactions' +import { UserRejectedSigningError } from '@perawallet/wallet-core-signing' import { useNavigation } from '@react-navigation/native' import type { StackNavigationProp } from '@react-navigation/stack' import type { SendFundsStackParamList } from '../../../routes/send-funds/types' @@ -88,6 +89,11 @@ export const useTransactionProcessingScreen = () => { }) }) .catch(error => { + if (error instanceof UserRejectedSigningError) { + // Silent navigation back — user already saw the overlay's cancel button. + navigation.goBack() + return + } logger.error('Transaction failed', { error }) const { title, body } = getMessage(error) showToast( From e509e95b1b10f2dcd5793875a18c4b84c23eb6b0 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:24:06 +0100 Subject: [PATCH 14/14] refactor: renaming transaction signer to local key transaction signer wherever applicable --- .../__tests__/TransactionSigningView.spec.tsx | 2 +- .../useTransactionConfirmationScreen.test.tsx | 2 +- apps/mobile/vitest.setup.ts | 2 +- ...s => useLocalKeyTransactionSigner.spec.ts} | 20 +- .../hooks/__tests__/useSigningRequest.spec.ts | 4 +- packages/signing/src/hooks/index.ts | 2 +- .../src/hooks/useLocalKeyTransactionSigner.ts | 243 ++++++++++++++++++ .../src/hooks/useSigningActorLifecycle.ts | 4 +- .../signing/src/hooks/useTransactionSigner.ts | 228 ---------------- packages/signing/src/machine/context.ts | 2 +- .../signing/createHardwareStrategy.ts | 4 +- .../signing/createLocalKeyStrategy.ts | 2 +- .../pipeline/signing/getSigningStrategy.ts | 2 +- 13 files changed, 266 insertions(+), 251 deletions(-) rename packages/signing/src/hooks/__tests__/{useTransactionSigner.spec.ts => useLocalKeyTransactionSigner.spec.ts} (92%) create mode 100644 packages/signing/src/hooks/useLocalKeyTransactionSigner.ts delete mode 100644 packages/signing/src/hooks/useTransactionSigner.ts diff --git a/apps/mobile/src/modules/signing/components/TransactionSigningView/__tests__/TransactionSigningView.spec.tsx b/apps/mobile/src/modules/signing/components/TransactionSigningView/__tests__/TransactionSigningView.spec.tsx index a1df0e2b9..7724bb150 100644 --- a/apps/mobile/src/modules/signing/components/TransactionSigningView/__tests__/TransactionSigningView.spec.tsx +++ b/apps/mobile/src/modules/signing/components/TransactionSigningView/__tests__/TransactionSigningView.spec.tsx @@ -168,7 +168,7 @@ vi.mock('@perawallet/wallet-core-accounts', async importOriginal => { >() return { ...actual, - useTransactionSigner: vi.fn(() => ({ + useLocalKeyTransactionSigner: vi.fn(() => ({ signTransactions: vi.fn().mockResolvedValue([]), })), useAllAccounts: vi.fn(() => []), diff --git a/apps/mobile/src/modules/transactions/screens/send-funds/TransactionConfirmationScreen/__tests__/useTransactionConfirmationScreen.test.tsx b/apps/mobile/src/modules/transactions/screens/send-funds/TransactionConfirmationScreen/__tests__/useTransactionConfirmationScreen.test.tsx index 6eba4e10f..8739c3674 100644 --- a/apps/mobile/src/modules/transactions/screens/send-funds/TransactionConfirmationScreen/__tests__/useTransactionConfirmationScreen.test.tsx +++ b/apps/mobile/src/modules/transactions/screens/send-funds/TransactionConfirmationScreen/__tests__/useTransactionConfirmationScreen.test.tsx @@ -52,7 +52,7 @@ vi.mock('@perawallet/wallet-core-accounts', () => ({ })) vi.mock('@perawallet/wallet-core-signing', () => ({ - useTransactionSigner: vi.fn(), + useLocalKeyTransactionSigner: vi.fn(), })) vi.mock('@perawallet/wallet-core-assets', () => ({ diff --git a/apps/mobile/vitest.setup.ts b/apps/mobile/vitest.setup.ts index 86de54caa..ce06854ee 100644 --- a/apps/mobile/vitest.setup.ts +++ b/apps/mobile/vitest.setup.ts @@ -2128,7 +2128,7 @@ vi.mock('@perawallet/wallet-core-accounts', () => { isPending: false, })), useFindAccountByAddress: vi.fn(() => null), - useTransactionSigner: vi.fn(() => ({ + useLocalKeyTransactionSigner: vi.fn(() => ({ signTransactions: vi.fn().mockResolvedValue([]), })), useArbitraryDataSigner: vi.fn(() => ({ diff --git a/packages/signing/src/hooks/__tests__/useTransactionSigner.spec.ts b/packages/signing/src/hooks/__tests__/useLocalKeyTransactionSigner.spec.ts similarity index 92% rename from packages/signing/src/hooks/__tests__/useTransactionSigner.spec.ts rename to packages/signing/src/hooks/__tests__/useLocalKeyTransactionSigner.spec.ts index 0616efda7..5acc4d1a2 100644 --- a/packages/signing/src/hooks/__tests__/useTransactionSigner.spec.ts +++ b/packages/signing/src/hooks/__tests__/useLocalKeyTransactionSigner.spec.ts @@ -63,7 +63,7 @@ vi.mock('@perawallet/wallet-core-blockchain', async () => { } }) -import { useTransactionSigner } from '../useTransactionSigner' +import { useLocalKeyTransactionSigner } from '../useLocalKeyTransactionSigner' const hdAccount = { address: 'HD_ADDR', @@ -113,7 +113,7 @@ const makeTxn = (senderAddr: string) => }, }) as never -describe('useTransactionSigner', () => { +describe('useLocalKeyTransactionSigner', () => { beforeEach(() => { mockGetKeyOrThrow.mockReset() mockWithHDSession.mockReset() @@ -135,7 +135,7 @@ describe('useTransactionSigner', () => { }), ) - const { result } = renderHook(() => useTransactionSigner()) + const { result } = renderHook(() => useLocalKeyTransactionSigner()) const txn = makeTxn('HD_ADDR') const signed = await result.current.signTransactions([txn], [0]) @@ -155,7 +155,7 @@ describe('useTransactionSigner', () => { }), ) - const { result } = renderHook(() => useTransactionSigner()) + const { result } = renderHook(() => useLocalKeyTransactionSigner()) const txn = makeTxn('ALGO25_ADDR') const signed = await result.current.signTransactions([txn], [0]) @@ -176,7 +176,7 @@ describe('useTransactionSigner', () => { }), ) - const { result } = renderHook(() => useTransactionSigner()) + const { result } = renderHook(() => useLocalKeyTransactionSigner()) const txn = makeTxn('REKEYED_ADDR') const signed = await result.current.signTransactions([txn], [0]) @@ -196,7 +196,7 @@ describe('useTransactionSigner', () => { }), ) - const { result } = renderHook(() => useTransactionSigner()) + const { result } = renderHook(() => useLocalKeyTransactionSigner()) const txn1 = makeTxn('ALGO25_ADDR') const txn2 = makeTxn('ALGO25_ADDR') @@ -210,7 +210,7 @@ describe('useTransactionSigner', () => { test('skips transactions for accounts not in the wallet', async () => { mockAccounts = [] - const { result } = renderHook(() => useTransactionSigner()) + const { result } = renderHook(() => useLocalKeyTransactionSigner()) const txn = makeTxn('UNKNOWN') const signed = await result.current.signTransactions([txn], [0]) @@ -224,7 +224,7 @@ describe('useTransactionSigner', () => { mockIsAlgo25Account.mockReturnValue(false) mockIsHDWalletAccount.mockReturnValue(false) - const { result } = renderHook(() => useTransactionSigner()) + const { result } = renderHook(() => useLocalKeyTransactionSigner()) const txn = makeTxn('REKEYED_ADDR') await expect( @@ -235,7 +235,7 @@ describe('useTransactionSigner', () => { test('rejects for unsupported account type', async () => { mockAccounts = [unsupportedAccount] - const { result } = renderHook(() => useTransactionSigner()) + const { result } = renderHook(() => useLocalKeyTransactionSigner()) const txn = makeTxn('UNKNOWN_ADDR') await expect( @@ -253,7 +253,7 @@ describe('useTransactionSigner', () => { acc => acc.type === 'hardware', ) - const { result } = renderHook(() => useTransactionSigner()) + const { result } = renderHook(() => useLocalKeyTransactionSigner()) await expect( result.current.signTransactions([makeTxn('LEDGER_ADDR')], [0]), diff --git a/packages/signing/src/hooks/__tests__/useSigningRequest.spec.ts b/packages/signing/src/hooks/__tests__/useSigningRequest.spec.ts index 4c1cd3ac0..611c474a5 100644 --- a/packages/signing/src/hooks/__tests__/useSigningRequest.spec.ts +++ b/packages/signing/src/hooks/__tests__/useSigningRequest.spec.ts @@ -44,8 +44,8 @@ vi.mock('@perawallet/wallet-core-shared', async importOriginal => { } }) -vi.mock('../useTransactionSigner', () => ({ - useTransactionSigner: vi.fn(() => ({ +vi.mock('../useLocalKeyTransactionSigner', () => ({ + useLocalKeyTransactionSigner: vi.fn(() => ({ signTransactions: vi.fn(), })), })) diff --git a/packages/signing/src/hooks/index.ts b/packages/signing/src/hooks/index.ts index 9b8ac5a51..b918b7400 100644 --- a/packages/signing/src/hooks/index.ts +++ b/packages/signing/src/hooks/index.ts @@ -17,4 +17,4 @@ export * from './useHardwareSigning' export * from './useSignAndSubmitGroup' export * from './useSigningPipeline' export * from './useSigningRequest' -export * from './useTransactionSigner' +export * from './useLocalKeyTransactionSigner' diff --git a/packages/signing/src/hooks/useLocalKeyTransactionSigner.ts b/packages/signing/src/hooks/useLocalKeyTransactionSigner.ts new file mode 100644 index 000000000..066597cbd --- /dev/null +++ b/packages/signing/src/hooks/useLocalKeyTransactionSigner.ts @@ -0,0 +1,243 @@ +/* + 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 { useAccountsStore } from '@perawallet/wallet-core-accounts' +import { useKMS } from '@perawallet/wallet-core-kms' +import { useCallback } from 'react' +import { + Address, + encodeAlgorandAddress, + PeraSignedTransaction, + PeraTransaction, + PeraTransactionGroup, + useTransactionEncoder, +} from '@perawallet/wallet-core-blockchain' +import { + isAlgo25Account, + isHDWalletAccount, +} from '@perawallet/wallet-core-accounts' +import type { + Algo25Account, + HDWalletAccount, + WalletAccount, +} from '@perawallet/wallet-core-accounts' +import { SIGNING_KEY_DOMAIN } from '../constants' + +export type UseLocalKeyTransactionSignerResult = { + signTransactions: ( + txnGroup: PeraTransaction[], + indexesToSign: number[], + ) => Promise +} + +export const useLocalKeyTransactionSigner = + (): UseLocalKeyTransactionSignerResult => { + const accounts = useAccountsStore(state => state.accounts) + const { getKeyOrThrow, withHDSession, withAlgo25Session } = useKMS() + const { encodeTransaction } = useTransactionEncoder() + + const signHDWalletTransactions = useCallback( + async ( + account: HDWalletAccount, + txns: PeraTransactionGroup, + ): Promise => { + const hdWalletDetails = account.hdWalletDetails + const key = getKeyOrThrow(account.keyPairId) + + return await withHDSession( + key, + SIGNING_KEY_DOMAIN, + async session => { + const signedTxns = txns.map(async txn => { + const encodedTransaction = encodeTransaction(txn) + + const signature = await session.signTransaction( + { + account: hdWalletDetails.account, + keyIndex: hdWalletDetails.keyIndex, + derivationType: + hdWalletDetails.derivationType, + }, + encodedTransaction, + ) + + const senderPublicKey = encodeAlgorandAddress( + txn.sender.publicKey, + ) + const signedTxn: PeraSignedTransaction = { + txn, + sig: signature, + authAddress: + account.address !== senderPublicKey + ? Address.fromString(account.address) + : undefined, + } + return signedTxn + }) + return Promise.all(signedTxns) + }, + ) + }, + [getKeyOrThrow, encodeTransaction, withHDSession], + ) + + const signAlgo25Transactions = useCallback( + async ( + account: Algo25Account, + txns: PeraTransactionGroup, + ): Promise => { + const key = getKeyOrThrow(account.keyPairId) + return await withAlgo25Session( + key, + SIGNING_KEY_DOMAIN, + async session => { + const signedTxns = txns.map(async txn => { + const encodedTransaction = encodeTransaction(txn) + const signature = + await session.signTransaction( + encodedTransaction, + ) + + const senderPublicKey = encodeAlgorandAddress( + txn.sender.publicKey, + ) + const signedTxn: PeraSignedTransaction = { + txn, + sig: signature, + authAddress: + account.address !== senderPublicKey + ? Address.fromString(account.address) + : undefined, + } + return signedTxn + }) + return Promise.all(signedTxns) + }, + ) + }, + [encodeTransaction, withAlgo25Session], + ) + + const signSingleAccountTransactions = useCallback( + async ( + account: WalletAccount, + txns: PeraTransactionGroup, + dontFollowRekey?: boolean, + ): Promise => { + if (account.rekeyAddress && !dontFollowRekey) { + const rekeyedAccount = + accounts.find( + a => a.address === account.rekeyAddress, + ) ?? null + if (!rekeyedAccount) { + return Promise.reject( + `No rekeyed account found for ${account.rekeyAddress}`, + ) + } + //rekeys don't chain, so only follow rekeys for one level + return signSingleAccountTransactions( + rekeyedAccount, + txns, + true, + ) + } + + if (isHDWalletAccount(account)) { + return signHDWalletTransactions( + account as HDWalletAccount, + txns, + ) + } + + if (isAlgo25Account(account)) { + return signAlgo25Transactions( + account as Algo25Account, + txns, + ) + } + + return Promise.reject( + `Unsupported account type ${account.type} for ${account.address}`, + ) + }, + [accounts, signHDWalletTransactions, signAlgo25Transactions], + ) + + const signTransactions = useCallback( + async ( + txnGroup: PeraTransaction[], + indexesToSign: number[], + ): Promise => { + // we want to group the transactions by account for signing efficiency + // but we must remember where they were originally in the array + const originalIndexes = txnGroup.map((txn, index) => ({ + index, + txn, + })) + const groupedByAccount = originalIndexes.reduce( + (acc, { index, txn }) => { + const account = accounts.find( + a => a.address === txn.sender.toString(), + ) + if (!account) { + return acc + } + if (!acc.has(account.address)) { + acc.set(account.address, []) + } + acc.get(account.address)?.push({ index, txn }) + return acc + }, + new Map< + string, + { index: number; txn: PeraTransaction }[] + >(), + ) + + // sign each group of transactions for the same account + const result = txnGroup.map(txn => ({ + txn, + })) as PeraSignedTransaction[] + await Promise.all( + Array.from(groupedByAccount.entries()).map(async entry => { + const accountAddress = entry[0] + const txns = entry[1] + const toSign = txns.filter(txnHolder => + indexesToSign.includes(txnHolder.index), + ) + + const account = accounts.find( + a => a.address === accountAddress, + ) + if (!account) { + return Promise.reject( + `No account found for ${accountAddress}`, + ) + } + const signedTxns = await signSingleAccountTransactions( + account, + toSign.map(txnHolder => txnHolder.txn), + ) + signedTxns.forEach((signedTxn, idx) => { + result[toSign[idx].index] = signedTxn + }) + }), + ) + return result + }, + [accounts, signSingleAccountTransactions], + ) + + return { + signTransactions, + } + } diff --git a/packages/signing/src/hooks/useSigningActorLifecycle.ts b/packages/signing/src/hooks/useSigningActorLifecycle.ts index 5e9c8ed4c..ab97b47e1 100644 --- a/packages/signing/src/hooks/useSigningActorLifecycle.ts +++ b/packages/signing/src/hooks/useSigningActorLifecycle.ts @@ -24,7 +24,7 @@ import { LedgerConnectionError, LedgerTimeoutError, } from '@perawallet/wallet-core-ledger' -import { useTransactionSigner } from './useTransactionSigner' +import { useLocalKeyTransactionSigner } from './useLocalKeyTransactionSigner' import { useArbitraryDataSigner } from './useArbitraryDataSigner' import { useArc60Signer } from './useArc60Signer' import { useSigningStore, useHardwareSigningStore } from '../store' @@ -118,7 +118,7 @@ export const useSigningActorLifecycle = (): UseSigningActorLifecycleResult => { state => state.setLastFailedRequest, ) - const { signTransactions } = useTransactionSigner() + const { signTransactions } = useLocalKeyTransactionSigner() const { signArbitraryData } = useArbitraryDataSigner() const { signArc60 } = useArc60Signer() const { encodeTransactionRaw, encodeSignedTransactions } = diff --git a/packages/signing/src/hooks/useTransactionSigner.ts b/packages/signing/src/hooks/useTransactionSigner.ts deleted file mode 100644 index 072e06d7a..000000000 --- a/packages/signing/src/hooks/useTransactionSigner.ts +++ /dev/null @@ -1,228 +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 { useAccountsStore } from '@perawallet/wallet-core-accounts' -import { useKMS } from '@perawallet/wallet-core-kms' -import { useCallback } from 'react' -import { - Address, - encodeAlgorandAddress, - PeraSignedTransaction, - PeraTransaction, - PeraTransactionGroup, - useTransactionEncoder, -} from '@perawallet/wallet-core-blockchain' -import { - isAlgo25Account, - isHDWalletAccount, -} from '@perawallet/wallet-core-accounts' -import type { - Algo25Account, - HDWalletAccount, - WalletAccount, -} from '@perawallet/wallet-core-accounts' -import { SIGNING_KEY_DOMAIN } from '../constants' - -export type UseTransactionSignerResult = { - signTransactions: ( - txnGroup: PeraTransaction[], - indexesToSign: number[], - ) => Promise -} - -export const useTransactionSigner = (): UseTransactionSignerResult => { - const accounts = useAccountsStore(state => state.accounts) - const { getKeyOrThrow, withHDSession, withAlgo25Session } = useKMS() - const { encodeTransaction } = useTransactionEncoder() - - const signHDWalletTransactions = useCallback( - async ( - account: HDWalletAccount, - txns: PeraTransactionGroup, - ): Promise => { - const hdWalletDetails = account.hdWalletDetails - const key = getKeyOrThrow(account.keyPairId) - - return await withHDSession( - key, - SIGNING_KEY_DOMAIN, - async session => { - const signedTxns = txns.map(async txn => { - const encodedTransaction = encodeTransaction(txn) - - const signature = await session.signTransaction( - { - account: hdWalletDetails.account, - keyIndex: hdWalletDetails.keyIndex, - derivationType: hdWalletDetails.derivationType, - }, - encodedTransaction, - ) - - const senderPublicKey = encodeAlgorandAddress( - txn.sender.publicKey, - ) - const signedTxn: PeraSignedTransaction = { - txn, - sig: signature, - authAddress: - account.address !== senderPublicKey - ? Address.fromString(account.address) - : undefined, - } - return signedTxn - }) - return Promise.all(signedTxns) - }, - ) - }, - [getKeyOrThrow, encodeTransaction, withHDSession], - ) - - const signAlgo25Transactions = useCallback( - async ( - account: Algo25Account, - txns: PeraTransactionGroup, - ): Promise => { - const key = getKeyOrThrow(account.keyPairId) - return await withAlgo25Session( - key, - SIGNING_KEY_DOMAIN, - async session => { - const signedTxns = txns.map(async txn => { - const encodedTransaction = encodeTransaction(txn) - const signature = - await session.signTransaction(encodedTransaction) - - const senderPublicKey = encodeAlgorandAddress( - txn.sender.publicKey, - ) - const signedTxn: PeraSignedTransaction = { - txn, - sig: signature, - authAddress: - account.address !== senderPublicKey - ? Address.fromString(account.address) - : undefined, - } - return signedTxn - }) - return Promise.all(signedTxns) - }, - ) - }, - [encodeTransaction, withAlgo25Session], - ) - - const signSingleAccountTransactions = useCallback( - async ( - account: WalletAccount, - txns: PeraTransactionGroup, - dontFollowRekey?: boolean, - ): Promise => { - if (account.rekeyAddress && !dontFollowRekey) { - const rekeyedAccount = - accounts.find(a => a.address === account.rekeyAddress) ?? - null - if (!rekeyedAccount) { - return Promise.reject( - `No rekeyed account found for ${account.rekeyAddress}`, - ) - } - //rekeys don't chain, so only follow rekeys for one level - return signSingleAccountTransactions(rekeyedAccount, txns, true) - } - - if (isHDWalletAccount(account)) { - return signHDWalletTransactions( - account as HDWalletAccount, - txns, - ) - } - - if (isAlgo25Account(account)) { - return signAlgo25Transactions(account as Algo25Account, txns) - } - - return Promise.reject( - `Unsupported account type ${account.type} for ${account.address}`, - ) - }, - [accounts, signHDWalletTransactions, signAlgo25Transactions], - ) - - const signTransactions = useCallback( - async ( - txnGroup: PeraTransaction[], - indexesToSign: number[], - ): Promise => { - // we want to group the transactions by account for signing efficiency - // but we must remember where they were originally in the array - const originalIndexes = txnGroup.map((txn, index) => ({ - index, - txn, - })) - const groupedByAccount = originalIndexes.reduce( - (acc, { index, txn }) => { - const account = accounts.find( - a => a.address === txn.sender.toString(), - ) - if (!account) { - return acc - } - if (!acc.has(account.address)) { - acc.set(account.address, []) - } - acc.get(account.address)?.push({ index, txn }) - return acc - }, - new Map(), - ) - - // sign each group of transactions for the same account - const result = txnGroup.map(txn => ({ - txn, - })) as PeraSignedTransaction[] - await Promise.all( - Array.from(groupedByAccount.entries()).map(async entry => { - const accountAddress = entry[0] - const txns = entry[1] - const toSign = txns.filter(txnHolder => - indexesToSign.includes(txnHolder.index), - ) - - const account = accounts.find( - a => a.address === accountAddress, - ) - if (!account) { - return Promise.reject( - `No account found for ${accountAddress}`, - ) - } - const signedTxns = await signSingleAccountTransactions( - account, - toSign.map(txnHolder => txnHolder.txn), - ) - signedTxns.forEach((signedTxn, idx) => { - result[toSign[idx].index] = signedTxn - }) - }), - ) - return result - }, - [accounts, signSingleAccountTransactions], - ) - - return { - signTransactions, - } -} diff --git a/packages/signing/src/machine/context.ts b/packages/signing/src/machine/context.ts index 2f04792e4..c58246e83 100644 --- a/packages/signing/src/machine/context.ts +++ b/packages/signing/src/machine/context.ts @@ -70,7 +70,7 @@ export type TransportFactory = ( * These are functions and clients that cannot be known at machine definition time. */ export type SigningMachineDeps = { - /** KMS signing function from useTransactionSigner */ + /** KMS signing function from useLocalKeyTransactionSigner */ signTransactions: LocalSigningFunction /** KMS arbitrary-data signing function from useArbitraryDataSigner */ signArbitraryData: LocalArbitrarySigningFunction diff --git a/packages/signing/src/pipeline/signing/createHardwareStrategy.ts b/packages/signing/src/pipeline/signing/createHardwareStrategy.ts index 257eb1377..7f3d717ec 100644 --- a/packages/signing/src/pipeline/signing/createHardwareStrategy.ts +++ b/packages/signing/src/pipeline/signing/createHardwareStrategy.ts @@ -271,8 +271,8 @@ export type SignTransactionsOnHardwareWalletOptions = { * disconnect. Returns a parallel array where indices listed in `indicesToSign` * are signed and all other entries are unsigned placeholders (`{ txn }` only). * - * Shared between the XState-based signing pipeline and the algokit-based - * `useTransactionSigner` flow so both paths get identical Ledger behavior. + * Used by the XState-based signing pipeline's hardware strategy. Local-key + * accounts sign through `useLocalKeyTransactionSigner` and never reach here. */ export const signTransactionsOnHardwareWallet = async ( hwAccount: HardwareWalletAccount, diff --git a/packages/signing/src/pipeline/signing/createLocalKeyStrategy.ts b/packages/signing/src/pipeline/signing/createLocalKeyStrategy.ts index 5184fd261..b5e0ba4b9 100644 --- a/packages/signing/src/pipeline/signing/createLocalKeyStrategy.ts +++ b/packages/signing/src/pipeline/signing/createLocalKeyStrategy.ts @@ -29,7 +29,7 @@ import type { import { CannotSignError, SigningError } from '../errors' /** - * Signing function type that matches useTransactionSigner's signTransactions + * Signing function type that matches useLocalKeyTransactionSigner's signTransactions */ export type LocalSigningFunction = ( txnGroup: PeraSignedTransaction['txn'][], diff --git a/packages/signing/src/pipeline/signing/getSigningStrategy.ts b/packages/signing/src/pipeline/signing/getSigningStrategy.ts index 7720e7318..782e26588 100644 --- a/packages/signing/src/pipeline/signing/getSigningStrategy.ts +++ b/packages/signing/src/pipeline/signing/getSigningStrategy.ts @@ -36,7 +36,7 @@ import { createMultisigStrategy } from './createMultisigStrategy' * Options for creating the signing strategy selector */ export interface GetSigningStrategyOptions { - /** Transaction signing function from useTransactionSigner */ + /** Transaction signing function from useLocalKeyTransactionSigner */ signTransactions: LocalSigningFunction /** Arbitrary-data signing function from useArbitraryDataSigner */