diff --git a/package.json b/package.json index 3efee985..218812fb 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,16 @@ ] }, "dependencies": { + "@ledgerhq/hw-app-str": "7.7.7", + "@ledgerhq/hw-transport-webhid": "6.36.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", + "@stellar/freighter-api": "6.0.1", + "@stellar/stellar-sdk": "17.0.1", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.90.12", "axios": "^1.13.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b9b27b9..7b1a7f94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,18 @@ settings: importers: .: dependencies: + '@ledgerhq/hw-app-str': + specifier: 7.7.7 + version: 7.7.7 + '@ledgerhq/hw-transport-webhid': + specifier: 6.36.0 + version: 6.36.0 + '@stellar/freighter-api': + specifier: 6.0.1 + version: 6.0.1 + '@stellar/stellar-sdk': + specifier: 17.0.1 + version: 17.0.1 '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -7081,6 +7093,111 @@ packages: use-sync-external-store: optional: true + '@ledgerhq/devices@8.16.0': + resolution: + integrity: sha512-brXLPzkvGM3D5YNsWQ25P5G4SmWdSNBed9W8wKoOIRLGdRfvE+bg9mzFty0iZ+aRLBkLoXwX7xKIL9zUi6LBKQ== + '@ledgerhq/devices@8.17.0': + resolution: + integrity: sha512-l+rrVQEjR1hSWOLD00LFX4zbS8yB1M/Mb6UYPmSHGO7TmE1CFbZ4CmDJC/kZSl3hdrXCJ+YUNpvaLCcSWDwA+Q== + '@ledgerhq/errors@6.37.0': + resolution: + integrity: sha512-T5yiKI5UX7ugeocdTF3TUsCIN2BH41Bio4ZeN410YFjFOf3es08n/5JyMzzKwzRgP0blG3HfBf7s7vJKqCSAeg== + '@ledgerhq/errors@7.0.0': + resolution: + integrity: sha512-+Q/vykUlNeIxiM+I3cu1B660WLkzlmIsHLTV9QNV5D2/Ocplx3QMg52NYq2X7OAfGQnfH1rQvhn/NrjT+t9wBA== + '@ledgerhq/hw-app-str@7.7.7': + resolution: + integrity: sha512-RNOacpfB9YI82o8Nw7oleec1I4a5jAmBtxyNeoyTPayXcnhbnLy6v14fzz7M4cNsNHOqFtWZ17LmrclOaU1P/g== + '@ledgerhq/hw-transport-webhid@6.36.0': + resolution: + integrity: sha512-1mKWm3LyGOgmaYlAiqbmaGoupOZHbj2Kow5sXLxKZzQa4kFvQuUdinYOWhs7T8hqJyAkz9WlHYyKM7aIs8kjNg== + '@ledgerhq/hw-transport@6.35.5': + resolution: + integrity: sha512-P4+wtLewLWgxPtIb90h5kjpzXVlC6f4IBQBmvowVFkInvZt34ffXkX7wa5KfMzu4l3cqCcpNSqtPSCMp+0vuqg== + '@ledgerhq/hw-transport@6.35.7': + resolution: + integrity: sha512-vVhAVQ56+7A5FY5Mr09HY+bmf3H6TXpwsj+/xbadNkjl//e/YzTWfpXk4eBnj3hwk1PQ9Mn6pKFH0BHjS2BKlg== + '@ledgerhq/logs@6.17.0': + resolution: + integrity: sha512-yra33g5q/AU7+PwAws+GaVpQGUuxnDREjVBnviJjcaJLVKuLzI4pnj8Bd3nY3fypM5k1yZEYKEXfUuGFUjP2+w== + '@noble/ed25519@3.2.0': + resolution: + integrity: sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ== + '@noble/hashes@2.4.0': + resolution: + integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA== + engines: + node: '>= 20.19.0' + '@stellar/freighter-api@6.0.1': + resolution: + integrity: sha512-eqwakEqSg+zoLuPpSbKyrX0pG8DQFzL/J5GtbfuMCmJI+h+oiC9pQ5C6QLc80xopZQKdGt8dUAFCmDMNdAG95w== + '@stellar/js-xdr@5.0.0': + resolution: + integrity: sha512-HBDNKnxr+ecdaEmbZ0mcKkirOF8tXXEbWSw34P3wT40Tn3g+u+cCH17xAWZymzscq8cojHB8340pR8QNVaD32w== + engines: + node: '>=22.0.0' + pnpm: '>=10.0.0' + '@stellar/stellar-sdk@17.0.1': + resolution: + integrity: sha512-fsHHbzJ14N5Ttq5Qpz4AX0ytmPSLCzcy4XC3uIgSQz0cBkEgv0Zb4KE6tWEd3nDQUk/1qP8ZX9cRonIaUXO3Sw== + engines: + node: '>=22.12.0' + hasBin: true + axios@1.18.0: + resolution: + integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw== + bignumber.js@11.1.5: + resolution: + integrity: sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw== + bip32-path@0.4.2: + resolution: + integrity: sha512-ZBMCELjJfcNMkz5bDuJ1WrYvjlhEF5k6mQ8vUr4N7MbVRsXei7ZOg8VhhwMfNiW68NWmLkgkc6WvTickrLGprQ== + commander@14.0.3: + resolution: + integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== + engines: + node: '>=20' + eventsource-parser@3.1.1: + resolution: + integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ== + engines: + node: '>=18.0.0' + eventsource@4.1.1: + resolution: + integrity: sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w== + engines: + node: '>=20.0.0' + feaxios@0.0.23: + resolution: + integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g== + is-retry-allowed@3.0.0: + resolution: + integrity: sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A== + engines: + node: '>=12' + semver@7.7.1: + resolution: + integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA== + engines: + node: '>=10' + hasBin: true + semver@7.7.3: + resolution: + integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + engines: + node: '>=10' + hasBin: true + smol-toml@1.8.0: + resolution: + integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ== + engines: + node: '>= 18' + uint8array-extras@1.5.0: + resolution: + integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A== + engines: + node: '>=18' + snapshots: '@adobe/css-tools@4.5.0': {} @@ -12105,3 +12222,155 @@ snapshots: '@types/react': 19.2.15 react: 19.2.6 use-sync-external-store: 1.4.0(react@19.2.6) + '@exodus/bytes@1.15.1(@noble/hashes@2.4.0)': + optionalDependencies: + '@noble/hashes': 2.4.0 + '@ledgerhq/devices@8.16.0': + dependencies: + semver: 7.7.3 + '@ledgerhq/devices@8.17.0': + dependencies: + semver: 7.7.3 + '@ledgerhq/errors@6.37.0': {} + '@ledgerhq/errors@7.0.0': {} + '@ledgerhq/hw-app-str@7.7.7': + dependencies: + '@ledgerhq/hw-transport': 6.35.7 + bip32-path: 0.4.2 + '@ledgerhq/hw-transport-webhid@6.36.0': + dependencies: + '@ledgerhq/devices': 8.16.0 + '@ledgerhq/errors': 6.37.0 + '@ledgerhq/hw-transport': 6.35.5 + '@ledgerhq/logs': 6.17.0 + '@ledgerhq/hw-transport@6.35.5': + dependencies: + '@ledgerhq/devices': 8.16.0 + '@ledgerhq/errors': 6.37.0 + '@ledgerhq/logs': 6.17.0 + events: 3.3.0 + '@ledgerhq/hw-transport@6.35.7': + dependencies: + '@ledgerhq/devices': 8.17.0 + '@ledgerhq/errors': 7.0.0 + '@ledgerhq/logs': 6.17.0 + events: 3.3.0 + '@ledgerhq/logs@6.17.0': {} + '@noble/ed25519@3.2.0': {} + '@noble/hashes@2.4.0': {} + '@stellar/freighter-api@6.0.1': + dependencies: + buffer: 6.0.3 + semver: 7.7.1 + '@stellar/js-xdr@5.0.0': {} + '@stellar/stellar-sdk@17.0.1': + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + '@noble/ed25519': 3.2.0 + '@noble/hashes': 2.4.0 + '@stellar/js-xdr': 5.0.0 + '@types/json-schema': 7.0.15 + axios: 1.18.0 + bignumber.js: 11.1.5 + commander: 14.0.3 + eventsource: 4.1.1 + feaxios: 0.0.23 + smol-toml: 1.8.0 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - debug + - supports-color + axios@1.18.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + bignumber.js@11.1.5: {} + bip32-path@0.4.2: {} + commander@14.0.3: {} + data-urls@7.0.0(@noble/hashes@2.4.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.4.0) + transitivePeerDependencies: + - '@noble/hashes' + eventsource-parser@3.1.1: {} + eventsource@4.1.1: + dependencies: + eventsource-parser: 3.1.1 + feaxios@0.0.23: + dependencies: + is-retry-allowed: 3.0.0 + html-encoding-sniffer@6.0.0(@noble/hashes@2.4.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + transitivePeerDependencies: + - '@noble/hashes' + is-retry-allowed@3.0.0: {} + jsdom@29.1.1(@noble/hashes@2.4.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.4.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.4.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.27.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.4.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + semver@7.7.1: {} + semver@7.7.3: {} + smol-toml@1.8.0: {} + uint8array-extras@1.5.0: {} + vitest@4.1.8(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.4 + jsdom: 29.1.1(@noble/hashes@2.4.0) + transitivePeerDependencies: + - msw + whatwg-url@16.0.1(@noble/hashes@2.4.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' diff --git a/src/components/common/SigningProgress.tsx b/src/components/common/SigningProgress.tsx new file mode 100644 index 00000000..bacbf8dc --- /dev/null +++ b/src/components/common/SigningProgress.tsx @@ -0,0 +1,84 @@ +import type { SigningError, SigningStage } from '@/lib/signing'; + +const STEPS: Array<{ stage: SigningStage; label: string }> = [ + { stage: 'preparing', label: 'Preparing transaction' }, + { stage: 'waiting-for-signature', label: 'Waiting for signature' }, + { stage: 'submitting', label: 'Submitting to Stellar' }, +]; + +const STAGE_INDEX: Partial> = { + idle: -1, + preparing: 0, + 'waiting-for-signature': 1, + submitting: 2, + complete: 3, +}; + +interface SigningProgressProps { + stage: SigningStage; + failedAt?: 'preparing' | 'waiting-for-signature' | 'submitting'; + error?: SigningError; + onCheckLedger?: () => void; +} + +export function SigningProgress({ + stage, + failedAt, + error, + onCheckLedger, +}: SigningProgressProps) { + const activeIndex = STAGE_INDEX[stage] ?? -1; + const failedIndex = + stage === 'failed' ? (STAGE_INDEX[failedAt ?? 'preparing'] ?? 0) : -1; + const showCheckLedger = + error?.type === 'LedgerLocked' || error?.type === 'LedgerAppNotOpen'; + + return ( +
+
    + {STEPS.map((step, index) => { + const complete = stage === 'complete' || index < activeIndex; + const active = index === activeIndex; + const failed = index === failedIndex; + return ( +
  1. + {' '} + {step.label} +
  2. + ); + })} +
+ {error && ( +
+

{error.hint}

+ {showCheckLedger && onCheckLedger && ( + + )} +
+ )} +
+ ); +} diff --git a/src/hooks/__tests__/useSigner.test.tsx b/src/hooks/__tests__/useSigner.test.tsx new file mode 100644 index 00000000..66473438 --- /dev/null +++ b/src/hooks/__tests__/useSigner.test.tsx @@ -0,0 +1,54 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { SIGNER_PREFERENCE_KEY, useSigner } from '../useSigner'; +import type { Signer } from '@/lib/signing'; + +const software: Signer = { + type: 'software', + sign: vi.fn(), + getPublicKey: vi.fn(), +}; +const hardware: Signer = { + type: 'hardware', + sign: vi.fn(), + getPublicKey: vi.fn(), +}; + +describe('useSigner', () => { + it('honors the stored hardware preference when both signers are available', async () => { + window.localStorage.setItem(SIGNER_PREFERENCE_KEY, 'hardware'); + const dependencies = { + detectSoftware: vi.fn().mockResolvedValue(true), + detectHardware: vi.fn().mockResolvedValue(true), + createSoftware: () => software, + createHardware: () => hardware, + }; + const { result } = renderHook(() => + useSigner('test network', dependencies) + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.preference).toBe('hardware'); + expect(result.current.activeSigner).toBe(hardware); + }); + + it('falls back to an available signer and persists preference changes', async () => { + window.localStorage.setItem(SIGNER_PREFERENCE_KEY, 'hardware'); + const dependencies = { + detectSoftware: vi.fn().mockResolvedValue(true), + detectHardware: vi.fn().mockResolvedValue(false), + createSoftware: () => software, + createHardware: () => hardware, + }; + const { result } = renderHook(() => + useSigner('test network', dependencies) + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.activeSigner).toBe(software); + result.current.setPreference('software'); + expect(window.localStorage.getItem(SIGNER_PREFERENCE_KEY)).toBe( + 'software' + ); + }); +}); diff --git a/src/hooks/useSigner.ts b/src/hooks/useSigner.ts new file mode 100644 index 00000000..23e5c47f --- /dev/null +++ b/src/hooks/useSigner.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + FreighterSigner, + LedgerSigner, + classifySigningError, + type Signer, + type SignerAvailability, + type SignerType, + type SigningError, +} from '@/lib/signing'; + +export const SIGNER_PREFERENCE_KEY = 'accesslayer.signer.preference'; + +export interface UseSignerDependencies { + detectSoftware?: () => Promise; + detectHardware?: () => Promise; + createSoftware?: () => Signer; + createHardware?: () => Signer; + storage?: Pick; +} + +export interface UseSignerResult { + activeSigner: Signer | null; + availableSigners: SignerAvailability; + preference: SignerType; + setPreference: (type: SignerType) => void; + loading: boolean; + error: SigningError | null; + refresh: () => Promise; +} + +const DEFAULT_DEPENDENCIES: UseSignerDependencies = {}; + +export function selectPreferredSigner( + availability: SignerAvailability, + preference: SignerType +): Signer | null { + return ( + availability[preference] ?? availability.software ?? availability.hardware + ); +} + +export function useSigner( + networkPassphrase: string, + dependencies: UseSignerDependencies = DEFAULT_DEPENDENCIES +): UseSignerResult { + const storage = + dependencies.storage ?? + (typeof window !== 'undefined' ? window.localStorage : undefined); + const [preference, setPreferenceState] = useState(() => + storage?.getItem(SIGNER_PREFERENCE_KEY) === 'hardware' + ? 'hardware' + : 'software' + ); + const [availableSigners, setAvailableSigners] = useState( + { + software: null, + hardware: null, + } + ); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [softwareAvailable, hardwareAvailable] = await Promise.all([ + (dependencies.detectSoftware ?? FreighterSigner.isAvailable)(), + (dependencies.detectHardware ?? LedgerSigner.isAvailable)(), + ]); + setAvailableSigners({ + software: softwareAvailable + ? (dependencies.createSoftware?.() ?? + new FreighterSigner(networkPassphrase)) + : null, + hardware: hardwareAvailable + ? (dependencies.createHardware?.() ?? + new LedgerSigner(networkPassphrase)) + : null, + }); + } catch (rawError) { + setAvailableSigners({ software: null, hardware: null }); + setError(classifySigningError(rawError)); + } finally { + setLoading(false); + } + }, [dependencies, networkPassphrase]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const setPreference = useCallback( + (type: SignerType) => { + storage?.setItem(SIGNER_PREFERENCE_KEY, type); + setPreferenceState(type); + }, + [storage] + ); + + const activeSigner = useMemo( + () => selectPreferredSigner(availableSigners, preference), + [availableSigners, preference] + ); + + return { + activeSigner, + availableSigners, + preference, + setPreference, + loading, + error, + refresh, + }; +} diff --git a/src/lib/signing/__tests__/errors.test.ts b/src/lib/signing/__tests__/errors.test.ts new file mode 100644 index 00000000..a828e930 --- /dev/null +++ b/src/lib/signing/__tests__/errors.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { classifySigningError } from '../errors'; + +describe('classifySigningError', () => { + it.each([ + [{ message: 'User rejected the request' }, 'UserRejected'], + [{ message: 'wallet is on the wrong network' }, 'NetworkMismatch'], + [{ statusCode: 0x5515, message: 'Device locked' }, 'LedgerLocked'], + [ + { statusCode: 0x6e00, message: 'App does not seem to be open' }, + 'LedgerAppNotOpen', + ], + [{ name: 'TransportError', message: 'Ledger timeout' }, 'LedgerTimeout'], + [ + { + name: 'StellarDataTooLargeError', + message: 'Data size exceeds maximum', + }, + 'TransactionTooLarge', + ], + [{ message: 'Freighter is not installed' }, 'SignerUnavailable'], + ] as const)('maps %o to %s', (rawError, expectedType) => { + const error = classifySigningError(rawError); + expect(error.type).toBe(expectedType); + expect(error.hint.length).toBeGreaterThan(10); + }); + + it('marks only Ledger timeouts as retryable', () => { + expect(classifySigningError(new Error('timed out')).retryable).toBe(true); + expect(classifySigningError(new Error('user rejected')).retryable).toBe( + false + ); + }); +}); diff --git a/src/lib/signing/__tests__/freighter-signer.test.ts b/src/lib/signing/__tests__/freighter-signer.test.ts new file mode 100644 index 00000000..c7a4faf8 --- /dev/null +++ b/src/lib/signing/__tests__/freighter-signer.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FreighterSigner, type FreighterApiAdapter } from '../freighter-signer'; + +function api( + overrides: Partial = {} +): FreighterApiAdapter { + return { + isConnected: vi.fn().mockResolvedValue({ isConnected: true }), + getAddress: vi.fn().mockResolvedValue({ address: 'GTEST' }), + getNetworkDetails: vi + .fn() + .mockResolvedValue({ networkPassphrase: 'test network' }), + signTransaction: vi.fn().mockResolvedValue({ signedTxXdr: 'signed-xdr' }), + ...overrides, + }; +} + +describe('FreighterSigner', () => { + it('signs only when Freighter is on the expected network', async () => { + const adapter = api(); + const signer = new FreighterSigner('test network', adapter); + await expect(signer.sign('unsigned-xdr')).resolves.toBe('signed-xdr'); + expect(adapter.signTransaction).toHaveBeenCalledWith('unsigned-xdr', { + networkPassphrase: 'test network', + address: 'GTEST', + }); + }); + + it('classifies a different Freighter network', async () => { + const signer = new FreighterSigner( + 'test network', + api({ + getNetworkDetails: vi + .fn() + .mockResolvedValue({ networkPassphrase: 'public network' }), + }) + ); + await expect(signer.sign('xdr')).rejects.toMatchObject({ + type: 'NetworkMismatch', + }); + }); +}); diff --git a/src/lib/signing/__tests__/multisig.test.ts b/src/lib/signing/__tests__/multisig.test.ts new file mode 100644 index 00000000..8bafa56c --- /dev/null +++ b/src/lib/signing/__tests__/multisig.test.ts @@ -0,0 +1,57 @@ +import { + Account, + Asset, + Keypair, + Networks, + Operation, + TransactionBuilder, +} from '@stellar/stellar-sdk'; +import { describe, expect, it } from 'vitest'; +import { aggregateSignedTransactions } from '../multisig'; + +function transactionXdr(sequence = '1'): string { + const source = Keypair.random(); + return new TransactionBuilder(new Account(source.publicKey(), sequence), { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.payment({ + destination: Keypair.random().publicKey(), + asset: Asset.native(), + amount: '1', + }) + ) + .setTimeout(60) + .build() + .toXDR(); +} + +describe('aggregateSignedTransactions', () => { + it('deduplicates and aggregates signatures for the same transaction', () => { + const unsigned = transactionXdr(); + const first = TransactionBuilder.fromXDR(unsigned, Networks.TESTNET); + const second = TransactionBuilder.fromXDR(unsigned, Networks.TESTNET); + first.sign(Keypair.random()); + second.sign(Keypair.random()); + + const aggregated = aggregateSignedTransactions( + unsigned, + [first.toXDR(), second.toXDR(), first.toXDR()], + Networks.TESTNET + ); + expect( + TransactionBuilder.fromXDR(aggregated, Networks.TESTNET).signatures + ).toHaveLength(2); + }); + + it('rejects signatures for a different transaction payload', () => { + expect(() => + aggregateSignedTransactions( + transactionXdr('1'), + [transactionXdr('2')], + Networks.TESTNET + ) + ).toThrow('different transaction payloads'); + }); +}); diff --git a/src/lib/signing/__tests__/pipeline.test.ts b/src/lib/signing/__tests__/pipeline.test.ts new file mode 100644 index 00000000..7babe890 --- /dev/null +++ b/src/lib/signing/__tests__/pipeline.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SigningPipelineError } from '../errors'; +import { executeSigningPipeline, signWithRetry } from '../pipeline'; +import type { Signer, SigningProgressState } from '../types'; + +function signer(type: Signer['type'], sign: Signer['sign']): Signer { + return { type, sign, getPublicKey: vi.fn().mockResolvedValue('GTEST') }; +} + +describe('signWithRetry', () => { + it('retries Ledger timeouts with 2s, 4s, and 8s delays', async () => { + const sign = vi + .fn() + .mockRejectedValueOnce(new SigningPipelineError('LedgerTimeout')) + .mockRejectedValueOnce(new SigningPipelineError('LedgerTimeout')) + .mockRejectedValueOnce(new SigningPipelineError('LedgerTimeout')) + .mockResolvedValue('signed-xdr'); + const sleep = vi.fn().mockResolvedValue(undefined); + const onRetry = vi.fn(); + + await expect( + signWithRetry(signer('hardware', sign), 'xdr', { sleep, onRetry }) + ).resolves.toBe('signed-xdr'); + expect(sign).toHaveBeenCalledTimes(4); + expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([ + 2_000, 4_000, 8_000, + ]); + expect(onRetry).toHaveBeenCalledTimes(3); + }); + + it('stops after three timeout retries', async () => { + const sign = vi + .fn() + .mockRejectedValue(new SigningPipelineError('LedgerTimeout')); + await expect( + signWithRetry(signer('hardware', sign), 'xdr', { + sleep: async () => undefined, + }) + ).rejects.toMatchObject({ type: 'LedgerTimeout' }); + expect(sign).toHaveBeenCalledTimes(4); + }); + + it.each(['LedgerLocked', 'LedgerAppNotOpen', 'UserRejected'] as const)( + 'does not retry %s', + async type => { + const sign = vi.fn().mockRejectedValue(new SigningPipelineError(type)); + await expect( + signWithRetry(signer('hardware', sign), 'xdr', { + sleep: async () => undefined, + }) + ).rejects.toMatchObject({ type }); + expect(sign).toHaveBeenCalledOnce(); + } + ); +}); + +describe('executeSigningPipeline', () => { + it('emits progress only after each async step completes', async () => { + const progress: SigningProgressState[] = []; + const submit = vi.fn().mockResolvedValue({ hash: 'abc' }); + const result = await executeSigningPipeline({ + prepare: vi.fn().mockResolvedValue('unsigned-xdr'), + signer: signer('software', vi.fn().mockResolvedValue('signed-xdr')), + submit, + onProgress: state => progress.push(state), + }); + + expect(result).toEqual({ hash: 'abc' }); + expect(submit).toHaveBeenCalledWith('signed-xdr'); + expect(progress.map(({ stage }) => stage)).toEqual([ + 'preparing', + 'waiting-for-signature', + 'submitting', + 'complete', + ]); + }); +}); diff --git a/src/lib/signing/errors.ts b/src/lib/signing/errors.ts new file mode 100644 index 00000000..514e150b --- /dev/null +++ b/src/lib/signing/errors.ts @@ -0,0 +1,124 @@ +import type { SigningError, SigningErrorType } from './types'; + +const ERROR_DETAILS: Record< + SigningErrorType, + { hint: string; retryable: boolean } +> = { + UserRejected: { + hint: 'Approve the request in your wallet when you are ready to continue.', + retryable: false, + }, + NetworkMismatch: { + hint: 'Switch your wallet to the network selected by AccessLayer and try again.', + retryable: false, + }, + LedgerLocked: { + hint: 'Unlock your Ledger and open the Stellar app.', + retryable: false, + }, + LedgerAppNotOpen: { + hint: 'Open the Stellar app on your Ledger.', + retryable: false, + }, + LedgerTimeout: { + hint: 'Keep your Ledger connected and confirm the request on the device.', + retryable: true, + }, + TransactionTooLarge: { + hint: 'This transaction is too large for the selected signer. Use a software wallet or reduce its operations.', + retryable: false, + }, + SignerUnavailable: { + hint: 'Install or reconnect a supported Stellar wallet and try again.', + retryable: false, + }, +}; + +export class SigningPipelineError extends Error implements SigningError { + readonly type: SigningErrorType; + readonly hint: string; + readonly retryable: boolean; + readonly cause?: unknown; + + constructor(type: SigningErrorType, message?: string, cause?: unknown) { + const details = ERROR_DETAILS[type]; + super(message ?? details.hint); + this.name = 'SigningPipelineError'; + this.type = type; + this.hint = details.hint; + this.retryable = details.retryable; + this.cause = cause; + } +} + +function readError(error: unknown): { message: string; code: string } { + if (typeof error === 'string') + return { message: error.toLowerCase(), code: '' }; + if (!error || typeof error !== 'object') return { message: '', code: '' }; + const value = error as Record; + const message = [value.message, value.name, value.error] + .filter((part): part is string => typeof part === 'string') + .join(' ') + .toLowerCase(); + const rawCode = value.statusCode ?? value.status ?? value.code ?? value.id; + const code = + typeof rawCode === 'number' + ? rawCode.toString(16) + : String(rawCode ?? '').toLowerCase(); + return { message, code }; +} + +/** Convert Freighter, WebHID, Ledger APDU, and browser failures into one UI-safe union. */ +export function classifySigningError(error: unknown): SigningPipelineError { + if (error instanceof SigningPipelineError) return error; + const { message, code } = readError(error); + const contains = (...values: string[]) => + values.some(value => message.includes(value) || code.includes(value)); + + if ( + contains( + 'user rejected', + 'user refused', + 'denied by the user', + 'request rejected', + '6985' + ) + ) { + return new SigningPipelineError('UserRejected', undefined, error); + } + if (contains('network mismatch', 'wrong network', 'different network')) { + return new SigningPipelineError('NetworkMismatch', undefined, error); + } + if (contains('device locked', 'ledger locked', 'locked device', '5515')) { + return new SigningPipelineError('LedgerLocked', undefined, error); + } + if ( + contains( + 'app does not seem to be open', + 'stellar app', + 'app not open', + '6e00', + '6d00' + ) + ) { + return new SigningPipelineError('LedgerAppNotOpen', undefined, error); + } + if ( + contains( + 'timeout', + 'timed out', + 'no response', + 'transport race condition' + ) + ) { + return new SigningPipelineError('LedgerTimeout', undefined, error); + } + if (contains('too large', 'data size', 'transactiontoolarge', '6a84')) { + return new SigningPipelineError('TransactionTooLarge', undefined, error); + } + return new SigningPipelineError('SignerUnavailable', undefined, error); +} + +export function signingErrorDetails(type: SigningErrorType) { + return ERROR_DETAILS[type]; +} diff --git a/src/lib/signing/freighter-signer.ts b/src/lib/signing/freighter-signer.ts new file mode 100644 index 00000000..7a78cdaf --- /dev/null +++ b/src/lib/signing/freighter-signer.ts @@ -0,0 +1,79 @@ +import { + getAddress, + getNetworkDetails, + isConnected, + signTransaction, +} from '@stellar/freighter-api'; +import { classifySigningError, SigningPipelineError } from './errors'; +import type { Signer } from './types'; + +export interface FreighterApiAdapter { + isConnected(): Promise<{ isConnected: boolean; error?: unknown }>; + getAddress(): Promise<{ address: string; error?: unknown }>; + getNetworkDetails(): Promise<{ networkPassphrase: string; error?: unknown }>; + signTransaction( + xdr: string, + options: { networkPassphrase: string; address?: string } + ): Promise<{ signedTxXdr: string; error?: unknown }>; +} + +const defaultApi: FreighterApiAdapter = { + isConnected, + getAddress, + getNetworkDetails, + signTransaction, +}; + +export class FreighterSigner implements Signer { + readonly type = 'software' as const; + private readonly networkPassphrase: string; + private readonly api: FreighterApiAdapter; + + constructor( + networkPassphrase: string, + api: FreighterApiAdapter = defaultApi + ) { + this.networkPassphrase = networkPassphrase; + this.api = api; + } + + static async isAvailable( + api: FreighterApiAdapter = defaultApi + ): Promise { + try { + const result = await api.isConnected(); + return result.isConnected && !result.error; + } catch { + return false; + } + } + + async getPublicKey(): Promise { + try { + const result = await this.api.getAddress(); + if (result.error || !result.address) throw result.error; + return result.address; + } catch (error) { + throw classifySigningError(error); + } + } + + async sign(xdr: string): Promise { + try { + const network = await this.api.getNetworkDetails(); + if (network.error) throw network.error; + if (network.networkPassphrase !== this.networkPassphrase) { + throw new SigningPipelineError('NetworkMismatch'); + } + const address = await this.getPublicKey(); + const result = await this.api.signTransaction(xdr, { + networkPassphrase: this.networkPassphrase, + address, + }); + if (result.error || !result.signedTxXdr) throw result.error; + return result.signedTxXdr; + } catch (error) { + throw classifySigningError(error); + } + } +} diff --git a/src/lib/signing/index.ts b/src/lib/signing/index.ts new file mode 100644 index 00000000..982d4b6a --- /dev/null +++ b/src/lib/signing/index.ts @@ -0,0 +1,6 @@ +export * from './errors'; +export * from './freighter-signer'; +export * from './ledger-signer'; +export * from './multisig'; +export * from './pipeline'; +export * from './types'; diff --git a/src/lib/signing/ledger-signer.ts b/src/lib/signing/ledger-signer.ts new file mode 100644 index 00000000..f0a2db47 --- /dev/null +++ b/src/lib/signing/ledger-signer.ts @@ -0,0 +1,141 @@ +import Str from '@ledgerhq/hw-app-str'; +import TransportWebHID from '@ledgerhq/hw-transport-webhid'; +import { StrKey, TransactionBuilder, xdr } from '@stellar/stellar-sdk'; +import { classifySigningError, SigningPipelineError } from './errors'; +import type { Signer } from './types'; + +const DEFAULT_PATH = "44'/148'/0'"; +const DEFAULT_TIMEOUT_MS = 30_000; + +type LedgerTransport = Awaited>; + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = window.setTimeout( + () => reject(new SigningPipelineError('LedgerTimeout')), + timeoutMs + ); + promise.then( + value => { + window.clearTimeout(timer); + resolve(value); + }, + error => { + window.clearTimeout(timer); + reject(error); + } + ); + }); +} + +export class LedgerSigner implements Signer { + readonly type = 'hardware' as const; + private transport: LedgerTransport | null = null; + private app: Str | null = null; + private readonly networkPassphrase: string; + private readonly path: string; + private readonly timeoutMs: number; + + constructor( + networkPassphrase: string, + path = DEFAULT_PATH, + timeoutMs = DEFAULT_TIMEOUT_MS + ) { + this.networkPassphrase = networkPassphrase; + this.path = path; + this.timeoutMs = timeoutMs; + } + + static async isAvailable(): Promise { + try { + return ( + typeof navigator !== 'undefined' && + (await TransportWebHID.isSupported()) + ); + } catch { + return false; + } + } + + private async connect(): Promise { + if (this.app) return this.app; + try { + this.transport = await TransportWebHID.create(); + this.transport.on('disconnect', () => { + this.transport = null; + this.app = null; + }); + this.app = new Str(this.transport); + return this.app; + } catch (error) { + throw classifySigningError(error); + } + } + + async healthCheck(): Promise { + try { + const app = await this.connect(); + await withTimeout(app.getAppConfiguration(), this.timeoutMs); + } catch (error) { + this.app = null; + this.transport = null; + throw classifySigningError(error); + } + } + + async getPublicKey(): Promise { + try { + await this.healthCheck(); + const result = await withTimeout( + this.app!.getPublicKey(this.path), + this.timeoutMs + ); + return StrKey.encodeEd25519PublicKey(result.rawPublicKey); + } catch (error) { + throw classifySigningError(error); + } + } + + async sign(transactionXdr: string): Promise { + try { + await this.healthCheck(); + const transaction = TransactionBuilder.fromXDR( + transactionXdr, + this.networkPassphrase + ); + const signatureBase = transaction.signatureBase(); + const configuration = await withTimeout( + this.app!.getAppConfiguration(), + this.timeoutMs + ); + if ( + configuration.maxDataSize && + signatureBase.length > configuration.maxDataSize + ) { + throw new SigningPipelineError('TransactionTooLarge'); + } + const [{ rawPublicKey }, { signature }] = await Promise.all([ + withTimeout(this.app!.getPublicKey(this.path), this.timeoutMs), + withTimeout( + this.app!.signTransaction(this.path, Buffer.from(signatureBase)), + this.timeoutMs + ), + ]); + transaction.signatures.push( + new xdr.DecoratedSignature({ + hint: rawPublicKey.subarray(rawPublicKey.length - 4), + signature, + }) + ); + return transaction.toXDR(); + } catch (error) { + throw classifySigningError(error); + } + } + + async disconnect(): Promise { + await this.transport?.close(); + this.transport = null; + this.app = null; + } +} diff --git a/src/lib/signing/multisig.ts b/src/lib/signing/multisig.ts new file mode 100644 index 00000000..3328023a --- /dev/null +++ b/src/lib/signing/multisig.ts @@ -0,0 +1,45 @@ +import { TransactionBuilder, xdr } from '@stellar/stellar-sdk'; +import { SigningPipelineError } from './errors'; + +function signatureKey(signature: xdr.DecoratedSignature): string { + return `${Buffer.from(signature.hint.value).toString('hex')}:${Buffer.from(signature.signature.value).toString('base64')}`; +} + +/** Aggregate signatures only when every payload represents the exact same transaction hash. */ +export function aggregateSignedTransactions( + unsignedXdr: string, + signedXdrs: readonly string[], + networkPassphrase: string +): string { + const aggregate = TransactionBuilder.fromXDR(unsignedXdr, networkPassphrase); + const expectedHash = Buffer.from(aggregate.hash()).toString('hex'); + const signatures = new Map( + aggregate.signatures.map(signature => [ + signatureKey(signature), + signature, + ]) + ); + + for (const signedXdr of signedXdrs) { + const candidate = TransactionBuilder.fromXDR( + signedXdr, + networkPassphrase + ); + if (Buffer.from(candidate.hash()).toString('hex') !== expectedHash) { + throw new SigningPipelineError( + 'NetworkMismatch', + 'Cannot aggregate signatures from different transaction payloads.' + ); + } + for (const signature of candidate.signatures) { + signatures.set(signatureKey(signature), signature); + } + } + + aggregate.signatures.splice( + 0, + aggregate.signatures.length, + ...signatures.values() + ); + return aggregate.toXDR(); +} diff --git a/src/lib/signing/pipeline.ts b/src/lib/signing/pipeline.ts new file mode 100644 index 00000000..190b7796 --- /dev/null +++ b/src/lib/signing/pipeline.ts @@ -0,0 +1,73 @@ +import { classifySigningError } from './errors'; +import type { Signer, SigningError, SigningProgressState } from './types'; + +const DEFAULT_RETRY_DELAYS = [2_000, 4_000, 8_000] as const; + +export interface SigningRetryOptions { + retryDelays?: readonly number[]; + sleep?: (milliseconds: number) => Promise; + onRetry?: (attempt: number, delayMs: number, error: SigningError) => void; +} + +export interface SigningPipelineOptions extends SigningRetryOptions { + prepare: () => Promise; + signer: Signer; + submit: (signedXdr: string) => Promise; + onProgress?: (state: SigningProgressState) => void; +} + +const defaultSleep = (milliseconds: number) => + new Promise(resolve => window.setTimeout(resolve, milliseconds)); + +/** Hardware timeouts retry at 2s/4s/8s; actionable and user-rejected errors never retry. */ +export async function signWithRetry( + signer: Signer, + xdr: string, + options: SigningRetryOptions = {} +): Promise { + const delays = options.retryDelays ?? DEFAULT_RETRY_DELAYS; + const sleep = options.sleep ?? defaultSleep; + let retries = 0; + + while (true) { + try { + return await signer.sign(xdr); + } catch (rawError) { + const error = classifySigningError(rawError); + if ( + signer.type !== 'hardware' || + error.type !== 'LedgerTimeout' || + retries >= delays.length + ) { + throw error; + } + const delay = delays[retries]; + retries += 1; + options.onRetry?.(retries, delay, error); + await sleep(delay); + } + } +} + +export async function executeSigningPipeline( + options: SigningPipelineOptions +): Promise { + try { + options.onProgress?.({ stage: 'preparing' }); + const xdr = await options.prepare(); + options.onProgress?.({ stage: 'waiting-for-signature' }); + const signedXdr = await signWithRetry(options.signer, xdr, { + retryDelays: options.retryDelays, + sleep: options.sleep, + onRetry: options.onRetry, + }); + options.onProgress?.({ stage: 'submitting' }); + const result = await options.submit(signedXdr); + options.onProgress?.({ stage: 'complete' }); + return result; + } catch (rawError) { + const error = classifySigningError(rawError); + options.onProgress?.({ stage: 'failed', error }); + throw error; + } +} diff --git a/src/lib/signing/types.ts b/src/lib/signing/types.ts new file mode 100644 index 00000000..b76fb1fe --- /dev/null +++ b/src/lib/signing/types.ts @@ -0,0 +1,41 @@ +export type SignerType = 'software' | 'hardware'; + +export interface Signer { + type: SignerType; + sign(xdr: string): Promise; + getPublicKey(): Promise; +} + +export type SigningErrorType = + | 'UserRejected' + | 'NetworkMismatch' + | 'LedgerLocked' + | 'LedgerAppNotOpen' + | 'LedgerTimeout' + | 'TransactionTooLarge' + | 'SignerUnavailable'; + +export interface SigningError { + type: SigningErrorType; + hint: string; + retryable: boolean; + cause?: unknown; +} + +export type SigningStage = + | 'idle' + | 'preparing' + | 'waiting-for-signature' + | 'submitting' + | 'complete' + | 'failed'; + +export interface SigningProgressState { + stage: SigningStage; + error?: SigningError; +} + +export interface SignerAvailability { + software: Signer | null; + hardware: Signer | null; +}