From 92b9c7c3690d212eaa5caaa70055ce5a32613263 Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Sun, 30 Aug 2026 10:58:54 +0100 Subject: [PATCH 1/2] feat(wallet): add typed Stellar signing pipeline (#758) --- package.json | 4 + pnpm-lock.yaml | 269 ++++++++++++++++++ src/components/common/SigningProgress.tsx | 84 ++++++ src/hooks/__tests__/useSigner.test.tsx | 54 ++++ src/hooks/useSigner.ts | 116 ++++++++ src/lib/signing/__tests__/errors.test.ts | 34 +++ .../__tests__/freighter-signer.test.ts | 42 +++ src/lib/signing/__tests__/multisig.test.ts | 57 ++++ src/lib/signing/__tests__/pipeline.test.ts | 77 +++++ src/lib/signing/errors.ts | 124 ++++++++ src/lib/signing/freighter-signer.ts | 79 +++++ src/lib/signing/index.ts | 6 + src/lib/signing/ledger-signer.ts | 141 +++++++++ src/lib/signing/multisig.ts | 45 +++ src/lib/signing/pipeline.ts | 73 +++++ src/lib/signing/types.ts | 41 +++ 16 files changed, 1246 insertions(+) create mode 100644 src/components/common/SigningProgress.tsx create mode 100644 src/hooks/__tests__/useSigner.test.tsx create mode 100644 src/hooks/useSigner.ts create mode 100644 src/lib/signing/__tests__/errors.test.ts create mode 100644 src/lib/signing/__tests__/freighter-signer.test.ts create mode 100644 src/lib/signing/__tests__/multisig.test.ts create mode 100644 src/lib/signing/__tests__/pipeline.test.ts create mode 100644 src/lib/signing/errors.ts create mode 100644 src/lib/signing/freighter-signer.ts create mode 100644 src/lib/signing/index.ts create mode 100644 src/lib/signing/ledger-signer.ts create mode 100644 src/lib/signing/multisig.ts create mode 100644 src/lib/signing/pipeline.ts create mode 100644 src/lib/signing/types.ts 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; +} From cbdf2ff950caa97e445bd5e61f1120403150cde2 Mon Sep 17 00:00:00 2001 From: Victor Edeh Date: Sun, 30 Aug 2026 15:18:42 +0100 Subject: [PATCH 2/2] fix(types): include co-creator payout fields --- src/services/course.service.ts | 626 +++++++++++++++++---------------- 1 file changed, 317 insertions(+), 309 deletions(-) diff --git a/src/services/course.service.ts b/src/services/course.service.ts index 3da0cdda..9b906a68 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -1,311 +1,319 @@ -// src/services/course.service.ts -import { BaseApiService, type APIResponse } from './api.service'; -import { cacheManager } from '@/utils/cache.utils'; - -export interface Course { - id: string; - title: string; - description: string; - price: number; - /** On-chain key price in stroops (preferred over legacy `price`). */ - priceStroops?: number; - /** ISO timestamp for the next scheduled drop, when applicable. */ - nextDropAt?: string; - creatorShareSupply?: number; - instructorId: string; - thumbnail?: string; - category: string; - level: 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'; - socialHandle?: string; - isVerified?: boolean; - volume24h?: number; - change24h?: number; - joinedAt?: string; - /** Whether this creator is pinned in the marketplace list. */ - isPinned?: boolean; - creatorFeeBps?: number; - protocolFeeBps?: number; - /** Last up to 7 price history points in stroops, oldest to newest. */ - priceHistory?: number[]; - holderCount?: number; - holdersCount?: number; - holders?: number; - /** XLM currently held in the staking reward pool for this key. */ - stakingPoolBalance?: number; - /** Number of keys staked across all holders. */ - totalStaked?: number; - /** Protocol fees that flowed into the staking pool over the last month. */ - recentFeeInflow?: number; - /** Editable creator metadata (falls back to title/description/thumbnail). */ - name?: string; - bio?: string; - avatarUri?: string; - /** Fixed auction price in XLM, when an auction has been configured. */ - auctionPrice?: number; - /** Number of keys allocated to the auction. */ - auctionSupply?: number; - /** Keys sold through the auction so far. */ - auctionSold?: number; - /** - * Early-sell penalty in basis points (0–2000 = 0%–20%). - * Applied to sells within the first 7 days after key creation. - */ +// src/services/course.service.ts +import { BaseApiService, type APIResponse } from './api.service'; +import { cacheManager } from '@/utils/cache.utils'; + +export interface Course { + id: string; + title: string; + description: string; + price: number; + /** On-chain key price in stroops (preferred over legacy `price`). */ + priceStroops?: number; + /** ISO timestamp for the next scheduled drop, when applicable. */ + nextDropAt?: string; + creatorShareSupply?: number; + instructorId: string; + thumbnail?: string; + category: string; + level: 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED'; + socialHandle?: string; + isVerified?: boolean; + volume24h?: number; + change24h?: number; + joinedAt?: string; + /** Whether this creator is pinned in the marketplace list. */ + isPinned?: boolean; + creatorFeeBps?: number; + protocolFeeBps?: number; + /** Last up to 7 price history points in stroops, oldest to newest. */ + priceHistory?: number[]; + holderCount?: number; + holdersCount?: number; + holders?: number; + /** XLM currently held in the staking reward pool for this key. */ + stakingPoolBalance?: number; + /** Number of keys staked across all holders. */ + totalStaked?: number; + /** Protocol fees that flowed into the staking pool over the last month. */ + recentFeeInflow?: number; + /** Editable creator metadata (falls back to title/description/thumbnail). */ + name?: string; + bio?: string; + avatarUri?: string; + /** Fixed auction price in XLM, when an auction has been configured. */ + auctionPrice?: number; + /** Number of keys allocated to the auction. */ + auctionSupply?: number; + /** Keys sold through the auction so far. */ + auctionSold?: number; + /** + * Early-sell penalty in basis points (0–2000 = 0%–20%). + * Applied to sells within the first 7 days after key creation. + */ launchPenaltyBps?: number; + /** Optional co-creator wallet configured for this creator key. */ + coCreatorAddress?: string; + /** Co-creator revenue share in basis points. */ + coCreatorSplitBps?: number; + /** Lifetime payout to the co-creator, expressed in stroops. */ + totalPaidToCoCreator?: number; + /** Lifetime payout to the primary creator, expressed in stroops. */ + totalPaidToCreator?: number; } - -export type CourseSortOption = - | 'volume_desc' - | 'price_asc' - | 'price_desc' - | 'newest'; - -export interface GetCoursesParams { - page?: number; - limit?: number; - category?: string; - search?: string; - min_price?: number; - max_price?: number; - sort?: CourseSortOption; -} - -/** Raw envelope shape for a paginated /courses response. */ -interface CoursesPageEnvelope { - items?: Course[]; - data?: Course[]; - has_more?: boolean; - hasMore?: boolean; -} - -export interface CoursesPage { - items: Course[]; - /** The page number that was requested (used as this page's cursor). */ - page: number; - /** Whether another page is available after this one. */ - hasMore: boolean; -} - -/** Single holder entry from the key holders endpoint. */ -export interface KeyHolderEntry { - id: string; - displayName: string; - walletAddress: string; - /** Total keys held by this holder, including any that are staked. */ - keyCount: number; - /** - * How many of `keyCount` are currently locked in the staking contract. - * Absent on responses from the pre-staking holders endpoint; callers - * should treat a missing value as `0`. - */ - stakedQuantity?: number; -} - -/** Cursor-paginated response envelope for the key holders endpoint. */ -export interface KeyHoldersPage { - holders: KeyHolderEntry[]; - nextCursor: string | null; -} - -class CourseService extends BaseApiService { - private readonly PROFILE_CACHE_TTL = 30000; // 30 seconds - - // Get all courses - GET /courses - async getCourses(params?: GetCoursesParams): Promise { - const cacheKey = `courses_${JSON.stringify(params || {})}`; - const cached = cacheManager.get(cacheKey); - if (cached) return cached; - - try { - const response = await this.api.get>( - '/courses', - { params } - ); - - const data = response.data.data; - cacheManager.set(cacheKey, data, this.PROFILE_CACHE_TTL); - return data; - } catch (error) { - throw this.handleError(error); - } - } - - /** - * Get one cursor-paginated page of courses for infinite-scroll marketplace - * browsing - GET /courses (#685). `page` is used as the cursor: pass the - * previous response's `page + 1` to fetch the next page. - * - * `hasMore` is read from the response's `has_more`/`hasMore` field when - * the backend provides it, falling back to "this page was full" (item - * count equals the requested limit) when it doesn't -- a full page means - * there could be more, an under-full page means we've reached the end. - */ - async getCoursesPage( - page: number, - params?: Omit - ): Promise { - const limit = params?.limit ?? 20; - const requestParams: GetCoursesParams = { ...params, page, limit }; - const cacheKey = `courses_page_${JSON.stringify(requestParams)}`; - const cached = cacheManager.get(cacheKey); - if (cached) return cached; - - try { - const response = await this.api.get< - APIResponse - >('/courses', { params: requestParams }); - - const raw = response.data.data; - const items: Course[] = Array.isArray(raw) - ? raw - : (raw.items ?? raw.data ?? []); - const hasMore: boolean = Array.isArray(raw) - ? items.length === limit - : (raw.has_more ?? raw.hasMore ?? items.length === limit); - - const result: CoursesPage = { items, page, hasMore }; - cacheManager.set(cacheKey, result, this.PROFILE_CACHE_TTL); - return result; - } catch (error) { - throw this.handleError(error); - } - } - - // Get single course - GET /courses/:id - async getCourse(courseId: string): Promise { - const cacheKey = `course_${courseId}`; - const cached = cacheManager.get(cacheKey); - if (cached) return cached; - - try { - const response = await this.api.get>( - `/courses/${courseId}` - ); - - const data = response.data.data; - cacheManager.set(cacheKey, data, this.PROFILE_CACHE_TTL); - return data; - } catch (error) { - throw this.handleError(error); - } - } - - // Get key holders - GET /keys/:keyId/holders - async getHoldersPage( - keyId: string, - cursor?: string | null - ): Promise { - try { - const params: Record = {}; - if (cursor) params.cursor = cursor; - - const response = await this.api.get>( - `/keys/${keyId}/holders`, - { params } - ); - - return response.data.data; - } catch (error) { - throw this.handleError(error); - } - } - - // Get enrolled courses - GET /courses/enrolled - async getEnrolledCourses(): Promise { - try { - const response = - await this.api.get>('/courses/enrolled'); - - return response.data.data; - } catch (error) { - throw this.handleError(error); - } - } - - // Enroll in course - POST /courses/:id/enroll - async enrollInCourse(courseId: string): Promise { - try { - await this.api.post(`/courses/${courseId}/enroll`); - } catch (error) { - throw this.handleError(error); - } - } - - // Create course - POST /courses - async createCourse(courseData: Partial): Promise { - try { - const response = await this.api.post>( - '/courses', - courseData - ); - - return response.data.data; - } catch (error) { - throw this.handleError(error); - } - } - - // Update course - PATCH /courses/:id - async updateCourse( - courseId: string, - courseData: Partial - ): Promise { - try { - const response = await this.api.patch>( - `/courses/${courseId}`, - courseData - ); - - return response.data.data; - } catch (error) { - throw this.handleError(error); - } - } - - // Search keys - GET /keys/search?q=:query - async searchKeys(query: string): Promise { - const trimmed = query.trim(); - if (!trimmed) return []; - - try { - const response = await this.api.get< - APIResponse - >('/keys/search', { - params: { q: trimmed }, - }); - - const raw = response.data.data; - if (Array.isArray(raw)) return raw; - if ( - raw && - typeof raw === 'object' && - 'items' in raw && - Array.isArray(raw.items) - ) { - return raw.items; - } - return []; - } catch (error) { - throw this.handleError(error); - } - } - - // Set co-creator address and split — POST /courses/:id/co-creator - async setCoCreator( - courseId: string, - address: string, - splitBps: number - ): Promise { - try { - const response = await this.api.post>( - `/courses/${courseId}/co-creator`, - { address, splitBps } - ); - - return response.data.data; - } catch (error) { - throw this.handleError(error); - } - } -} - -export const courseService = new CourseService(); + +export type CourseSortOption = + | 'volume_desc' + | 'price_asc' + | 'price_desc' + | 'newest'; + +export interface GetCoursesParams { + page?: number; + limit?: number; + category?: string; + search?: string; + min_price?: number; + max_price?: number; + sort?: CourseSortOption; +} + +/** Raw envelope shape for a paginated /courses response. */ +interface CoursesPageEnvelope { + items?: Course[]; + data?: Course[]; + has_more?: boolean; + hasMore?: boolean; +} + +export interface CoursesPage { + items: Course[]; + /** The page number that was requested (used as this page's cursor). */ + page: number; + /** Whether another page is available after this one. */ + hasMore: boolean; +} + +/** Single holder entry from the key holders endpoint. */ +export interface KeyHolderEntry { + id: string; + displayName: string; + walletAddress: string; + /** Total keys held by this holder, including any that are staked. */ + keyCount: number; + /** + * How many of `keyCount` are currently locked in the staking contract. + * Absent on responses from the pre-staking holders endpoint; callers + * should treat a missing value as `0`. + */ + stakedQuantity?: number; +} + +/** Cursor-paginated response envelope for the key holders endpoint. */ +export interface KeyHoldersPage { + holders: KeyHolderEntry[]; + nextCursor: string | null; +} + +class CourseService extends BaseApiService { + private readonly PROFILE_CACHE_TTL = 30000; // 30 seconds + + // Get all courses - GET /courses + async getCourses(params?: GetCoursesParams): Promise { + const cacheKey = `courses_${JSON.stringify(params || {})}`; + const cached = cacheManager.get(cacheKey); + if (cached) return cached; + + try { + const response = await this.api.get>( + '/courses', + { params } + ); + + const data = response.data.data; + cacheManager.set(cacheKey, data, this.PROFILE_CACHE_TTL); + return data; + } catch (error) { + throw this.handleError(error); + } + } + + /** + * Get one cursor-paginated page of courses for infinite-scroll marketplace + * browsing - GET /courses (#685). `page` is used as the cursor: pass the + * previous response's `page + 1` to fetch the next page. + * + * `hasMore` is read from the response's `has_more`/`hasMore` field when + * the backend provides it, falling back to "this page was full" (item + * count equals the requested limit) when it doesn't -- a full page means + * there could be more, an under-full page means we've reached the end. + */ + async getCoursesPage( + page: number, + params?: Omit + ): Promise { + const limit = params?.limit ?? 20; + const requestParams: GetCoursesParams = { ...params, page, limit }; + const cacheKey = `courses_page_${JSON.stringify(requestParams)}`; + const cached = cacheManager.get(cacheKey); + if (cached) return cached; + + try { + const response = await this.api.get< + APIResponse + >('/courses', { params: requestParams }); + + const raw = response.data.data; + const items: Course[] = Array.isArray(raw) + ? raw + : (raw.items ?? raw.data ?? []); + const hasMore: boolean = Array.isArray(raw) + ? items.length === limit + : (raw.has_more ?? raw.hasMore ?? items.length === limit); + + const result: CoursesPage = { items, page, hasMore }; + cacheManager.set(cacheKey, result, this.PROFILE_CACHE_TTL); + return result; + } catch (error) { + throw this.handleError(error); + } + } + + // Get single course - GET /courses/:id + async getCourse(courseId: string): Promise { + const cacheKey = `course_${courseId}`; + const cached = cacheManager.get(cacheKey); + if (cached) return cached; + + try { + const response = await this.api.get>( + `/courses/${courseId}` + ); + + const data = response.data.data; + cacheManager.set(cacheKey, data, this.PROFILE_CACHE_TTL); + return data; + } catch (error) { + throw this.handleError(error); + } + } + + // Get key holders - GET /keys/:keyId/holders + async getHoldersPage( + keyId: string, + cursor?: string | null + ): Promise { + try { + const params: Record = {}; + if (cursor) params.cursor = cursor; + + const response = await this.api.get>( + `/keys/${keyId}/holders`, + { params } + ); + + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } + + // Get enrolled courses - GET /courses/enrolled + async getEnrolledCourses(): Promise { + try { + const response = + await this.api.get>('/courses/enrolled'); + + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } + + // Enroll in course - POST /courses/:id/enroll + async enrollInCourse(courseId: string): Promise { + try { + await this.api.post(`/courses/${courseId}/enroll`); + } catch (error) { + throw this.handleError(error); + } + } + + // Create course - POST /courses + async createCourse(courseData: Partial): Promise { + try { + const response = await this.api.post>( + '/courses', + courseData + ); + + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } + + // Update course - PATCH /courses/:id + async updateCourse( + courseId: string, + courseData: Partial + ): Promise { + try { + const response = await this.api.patch>( + `/courses/${courseId}`, + courseData + ); + + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } + + // Search keys - GET /keys/search?q=:query + async searchKeys(query: string): Promise { + const trimmed = query.trim(); + if (!trimmed) return []; + + try { + const response = await this.api.get< + APIResponse + >('/keys/search', { + params: { q: trimmed }, + }); + + const raw = response.data.data; + if (Array.isArray(raw)) return raw; + if ( + raw && + typeof raw === 'object' && + 'items' in raw && + Array.isArray(raw.items) + ) { + return raw.items; + } + return []; + } catch (error) { + throw this.handleError(error); + } + } + + // Set co-creator address and split — POST /courses/:id/co-creator + async setCoCreator( + courseId: string, + address: string, + splitBps: number + ): Promise { + try { + const response = await this.api.post>( + `/courses/${courseId}/co-creator`, + { address, splitBps } + ); + + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const courseService = new CourseService();