@global-torque/invest-core 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +11 -0
  4. package/README.md +76 -0
  5. package/SECURITY.md +9 -0
  6. package/SUPPORT.md +6 -0
  7. package/dist/node/app/config.js +173 -0
  8. package/dist/node/helpers/text.js +37 -0
  9. package/dist/node/markdown/tableWrap.js +23 -0
  10. package/package.json +113 -0
  11. package/src/accreditation/status.ts +100 -0
  12. package/src/analytics/__tests__/analyticsBody.test.ts +50 -0
  13. package/src/analytics/analyticsBody.ts +270 -0
  14. package/src/app/config.test.ts +79 -0
  15. package/src/app/config.ts +329 -0
  16. package/src/decimal/__tests__/canonicalDecimal.test.ts +58 -0
  17. package/src/decimal/canonicalDecimal.ts +154 -0
  18. package/src/evm/__tests__/walletInfo.test.ts +488 -0
  19. package/src/evm/walletInfo.ts +625 -0
  20. package/src/filer/__tests__/documentFormatter.test.ts +208 -0
  21. package/src/filer/__tests__/publicImage.test.ts +42 -0
  22. package/src/filer/documentFormatter.ts +195 -0
  23. package/src/filer/publicImage.ts +120 -0
  24. package/src/form-validation/__tests__/general.test.ts +78 -0
  25. package/src/form-validation/__tests__/investment.test.ts +100 -0
  26. package/src/form-validation/ajv.ts +109 -0
  27. package/src/form-validation/constants.ts +22 -0
  28. package/src/form-validation/general.ts +114 -0
  29. package/src/form-validation/index.ts +5 -0
  30. package/src/form-validation/investment.ts +65 -0
  31. package/src/form-validation/rules.ts +35 -0
  32. package/src/formatting/__tests__/buildInfo.test.ts +19 -0
  33. package/src/formatting/__tests__/dateTime.test.ts +24 -0
  34. package/src/formatting/__tests__/display.test.ts +30 -0
  35. package/src/formatting/buildInfo.ts +31 -0
  36. package/src/formatting/dateTime.ts +43 -0
  37. package/src/formatting/display.ts +24 -0
  38. package/src/helpers/arrays.ts +11 -0
  39. package/src/helpers/currency.ts +19 -0
  40. package/src/helpers/formatters/formatToDate.ts +47 -0
  41. package/src/helpers/formatters/formatToNumber.ts +39 -0
  42. package/src/helpers/formatters/formatToPhone.ts +13 -0
  43. package/src/helpers/general.ts +164 -0
  44. package/src/helpers/model.ts +87 -0
  45. package/src/helpers/numberFormatter.ts +4 -0
  46. package/src/helpers/text.ts +51 -0
  47. package/src/index.ts +22 -0
  48. package/src/investment/__tests__/status.test.ts +59 -0
  49. package/src/investment/rawAmount.test.ts +23 -0
  50. package/src/investment/rawAmount.ts +81 -0
  51. package/src/investment/status.ts +56 -0
  52. package/src/kyc/__tests__/kycAlert.formatter.test.ts +47 -0
  53. package/src/kyc/__tests__/kycAlert.test.ts +47 -0
  54. package/src/kyc/__tests__/thirdPartyScreen.test.ts +16 -0
  55. package/src/kyc/kycAlert.ts +47 -0
  56. package/src/kyc/status.ts +109 -0
  57. package/src/kyc/thirdPartyScreen.ts +28 -0
  58. package/src/markdown/tableWrap.ts +29 -0
  59. package/src/notifications/shareFields.ts +15 -0
  60. package/src/offer/__tests__/metrics.test.ts +67 -0
  61. package/src/offer/formatter.ts +559 -0
  62. package/src/offer/metrics.ts +83 -0
  63. package/src/onboarding/__tests__/intents.test.ts +83 -0
  64. package/src/onboarding/intents.ts +128 -0
  65. package/src/profiles/__tests__/formatting.test.ts +24 -0
  66. package/src/profiles/avatarInitial.ts +5 -0
  67. package/src/profiles/formatting.ts +38 -0
  68. package/src/repository/__tests__/formatterCache.test.ts +45 -0
  69. package/src/repository/formatterCache.ts +56 -0
  70. package/src/wallet/__tests__/auth.test.ts +102 -0
  71. package/src/wallet/__tests__/operationPresentation.test.ts +94 -0
  72. package/src/wallet/__tests__/setupError.test.ts +32 -0
  73. package/src/wallet/auth.ts +491 -0
  74. package/src/wallet/operationPresentation.ts +106 -0
  75. package/src/wallet/setupError.ts +50 -0
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ buildProfileCreationHandoff,
4
+ buildWalletOnboardingIntent,
5
+ parseExactOrigin,
6
+ parseLocalContinuation,
7
+ parsePositiveProfileId,
8
+ parseSignupProfileType,
9
+ parseWalletOnboardingProfileType,
10
+ } from '../intents';
11
+
12
+ describe('onboarding intent parsing', () => {
13
+ it.each(['individual', 'entity', 'trust'])('accepts signup profile type %s', (value) => {
14
+ expect(parseSignupProfileType(value)).toBe(value);
15
+ });
16
+
17
+ it.each(['sdira', 'solo401k', '', 'entity<script>'])('rejects closed profile value %s', (value) => {
18
+ expect(parseSignupProfileType(value)).toBeNull();
19
+ });
20
+
21
+ it.each(['individual', 'entity', 'trust', 'sdira', 'solo401k'])(
22
+ 'accepts wallet-onboarding profile type %s',
23
+ (value) => expect(parseWalletOnboardingProfileType(value)).toBe(value),
24
+ );
25
+
26
+ it.each([[1, 1], ['42', 42], ['profile_8', 8], ['profile:9', 9], ['investor-10', 10]])(
27
+ 'parses exact positive profile id %s',
28
+ (value, expected) => expect(parsePositiveProfileId(value)).toBe(expected),
29
+ );
30
+
31
+ it.each([0, '0', -1, '1.5', 'profile_0', '1/next', Number.MAX_SAFE_INTEGER + 1])(
32
+ 'rejects invalid profile id %s',
33
+ (value) => expect(parsePositiveProfileId(value)).toBeNull(),
34
+ );
35
+
36
+ it('allows only local allowlisted continuations', () => {
37
+ expect(parseLocalContinuation('/profile/8/wallet-otp?next=1', ['/profile'], '/fallback'))
38
+ .toBe('/profile/8/wallet-otp?next=1');
39
+ for (const hostile of ['https://evil.test/x', '//evil.test/x', '/admin', '/profile\\evil']) {
40
+ expect(parseLocalContinuation(hostile, ['/profile'], '/fallback')).toBe('/fallback');
41
+ }
42
+ });
43
+
44
+ it('accepts exact configured origins without paths or credentials', () => {
45
+ expect(parseExactOrigin('https://dashboard.example.test/path')).toBe('https://dashboard.example.test');
46
+ expect(parseExactOrigin('https://user:pass@example.test')).toBeNull();
47
+ expect(parseExactOrigin('javascript:alert(1)')).toBeNull();
48
+ });
49
+
50
+ it('builds exact wallet and entity handoff contracts', () => {
51
+ expect(buildWalletOnboardingIntent({
52
+ profileId: 'profile_7',
53
+ profileType: 'sdira',
54
+ nextPath: '/profile/7/kyc',
55
+ })).toEqual({
56
+ profileId: 7,
57
+ expectedProfileType: 'sdira',
58
+ nextPath: '/profile/7/kyc',
59
+ });
60
+ // Continuations outside /profile fall back to the profile account page.
61
+ expect(buildWalletOnboardingIntent({
62
+ profileId: 'profile_7',
63
+ profileType: 'sdira',
64
+ nextPath: '/investor/onboarding',
65
+ })).toEqual({
66
+ profileId: 7,
67
+ expectedProfileType: 'sdira',
68
+ nextPath: '/profile/7/account',
69
+ });
70
+ expect(buildProfileCreationHandoff({
71
+ profileType: 'entity',
72
+ nextPath: '/profile',
73
+ acceptedProfileId: 'profile_11',
74
+ firstName: ' Ada ',
75
+ })).toEqual({
76
+ onboarding: 'signup',
77
+ profileType: 'entity',
78
+ nextPath: '/profile',
79
+ acceptedProfileId: 11,
80
+ firstName: 'Ada',
81
+ });
82
+ });
83
+ });
@@ -0,0 +1,128 @@
1
+ import {
2
+ SIGNUP_PROFILE_TYPES,
3
+ WALLET_ONBOARDING_PROFILE_TYPES,
4
+ type ProfileCreationHandoff,
5
+ type SignupProfileType,
6
+ type WalletOnboardingIntent,
7
+ type WalletOnboardingProfileType,
8
+ } from '@global-torque/domain-types/onboardingTypes';
9
+
10
+ const SIGNUP_PROFILE_TYPE_SET = new Set<string>(SIGNUP_PROFILE_TYPES);
11
+ const WALLET_PROFILE_TYPE_SET = new Set<string>(WALLET_ONBOARDING_PROFILE_TYPES);
12
+ const MAX_PREFILL_LENGTH = 100;
13
+
14
+ export const parseSignupProfileType = (value: unknown): SignupProfileType | null => {
15
+ const normalized = String(value ?? '').trim().toLowerCase();
16
+ return SIGNUP_PROFILE_TYPE_SET.has(normalized)
17
+ ? normalized as SignupProfileType
18
+ : null;
19
+ };
20
+
21
+ export const parseWalletOnboardingProfileType = (
22
+ value: unknown,
23
+ ): WalletOnboardingProfileType | null => {
24
+ const normalized = String(value ?? '').trim().toLowerCase();
25
+ return WALLET_PROFILE_TYPE_SET.has(normalized)
26
+ ? normalized as WalletOnboardingProfileType
27
+ : null;
28
+ };
29
+
30
+ export const parsePositiveProfileId = (value: unknown): number | null => {
31
+ const normalized = String(value ?? '').trim().replace(/^(?:profile|investor)[_:-]/i, '');
32
+ if (!/^[1-9]\d*$/.test(normalized)) {
33
+ return null;
34
+ }
35
+ const profileId = Number(normalized);
36
+ return Number.isSafeInteger(profileId) ? profileId : null;
37
+ };
38
+
39
+ export const parseLocalContinuation = (
40
+ value: unknown,
41
+ allowedPrefixes: readonly string[],
42
+ fallback: string,
43
+ ): string => {
44
+ const candidate = String(value ?? '').trim();
45
+ if (
46
+ !candidate.startsWith('/')
47
+ || candidate.startsWith('//')
48
+ || candidate.includes('\\')
49
+ || /[\u0000-\u001f]/.test(candidate)
50
+ ) {
51
+ return fallback;
52
+ }
53
+ const pathname = candidate.split(/[?#]/, 1)[0] ?? '';
54
+ return allowedPrefixes.some((prefix) => (
55
+ pathname === prefix || pathname.startsWith(`${prefix}/`)
56
+ )) ? candidate : fallback;
57
+ };
58
+
59
+ export const parseExactOrigin = (value: unknown): string | null => {
60
+ try {
61
+ const url = new URL(String(value ?? '').trim());
62
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
63
+ return null;
64
+ }
65
+ return url.origin;
66
+ } catch {
67
+ return null;
68
+ }
69
+ };
70
+
71
+ export const boundedSignupPrefill = (value: unknown) => {
72
+ const normalized = String(value ?? '').trim();
73
+ return normalized && normalized.length <= MAX_PREFILL_LENGTH ? normalized : undefined;
74
+ };
75
+
76
+ export const buildWalletOnboardingIntent = ({
77
+ profileId,
78
+ profileType,
79
+ nextPath,
80
+ }: {
81
+ profileId: unknown;
82
+ profileType: unknown;
83
+ nextPath: unknown;
84
+ }): WalletOnboardingIntent | null => {
85
+ const parsedId = parsePositiveProfileId(profileId);
86
+ const parsedType = parseWalletOnboardingProfileType(profileType);
87
+ if (!parsedId || !parsedType) {
88
+ return null;
89
+ }
90
+ return {
91
+ profileId: parsedId,
92
+ expectedProfileType: parsedType,
93
+ nextPath: parseLocalContinuation(nextPath, ['/profile'], `/profile/${parsedId}/account`),
94
+ };
95
+ };
96
+
97
+ export const buildProfileCreationHandoff = ({
98
+ profileType,
99
+ nextPath,
100
+ acceptedProfileId,
101
+ firstName,
102
+ lastName,
103
+ email,
104
+ }: {
105
+ profileType: unknown;
106
+ nextPath: unknown;
107
+ acceptedProfileId?: unknown;
108
+ firstName?: unknown;
109
+ lastName?: unknown;
110
+ email?: unknown;
111
+ }): ProfileCreationHandoff | null => {
112
+ const parsedType = parseSignupProfileType(profileType);
113
+ if (parsedType !== 'entity' && parsedType !== 'trust') {
114
+ return null;
115
+ }
116
+ const parsedAcceptedId = acceptedProfileId == null
117
+ ? undefined
118
+ : parsePositiveProfileId(acceptedProfileId) ?? undefined;
119
+ return {
120
+ onboarding: 'signup',
121
+ profileType: parsedType,
122
+ nextPath: parseLocalContinuation(nextPath, ['/profile'], '/profile'),
123
+ ...(boundedSignupPrefill(firstName) && { firstName: boundedSignupPrefill(firstName) }),
124
+ ...(boundedSignupPrefill(lastName) && { lastName: boundedSignupPrefill(lastName) }),
125
+ ...(boundedSignupPrefill(email) && { email: boundedSignupPrefill(email) }),
126
+ ...(parsedAcceptedId && { acceptedProfileId: parsedAcceptedId }),
127
+ };
128
+ };
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ formatProfileDateToShortMonthDateYear,
4
+ formatProfilePhoneNumberForDisplay,
5
+ formatProfileUserPhoneNumber,
6
+ } from '../formatting.ts';
7
+
8
+ describe('profile formatting', () => {
9
+ it('keeps the existing short month account-created date format', () => {
10
+ expect(formatProfileDateToShortMonthDateYear(new Date(2026, 3, 8))).toBe('Apr 8, 2026');
11
+ });
12
+
13
+ it('formats user phone numbers only for 11-digit US numbers with a leading country code', () => {
14
+ expect(formatProfileUserPhoneNumber('+1 (555) 123-4567')).toBe('+1 (555) 123 - 4567');
15
+ expect(formatProfileUserPhoneNumber('5551234567')).toBeUndefined();
16
+ expect(formatProfileUserPhoneNumber('+25551234567')).toBeUndefined();
17
+ });
18
+
19
+ it('formats profile detail phone numbers with optional country codes', () => {
20
+ expect(formatProfilePhoneNumberForDisplay('+15551234567')).toBe('+1 (555) 123-4567');
21
+ expect(formatProfilePhoneNumberForDisplay('5559876543')).toBe('(555) 987-6543');
22
+ expect(formatProfilePhoneNumberForDisplay(undefined)).toBeUndefined();
23
+ });
24
+ });
@@ -0,0 +1,5 @@
1
+ export const getProfileAvatarInitial = (profileName?: string | null) => {
2
+ const trimmedProfileName = profileName?.trim();
3
+
4
+ return trimmedProfileName ? trimmedProfileName.charAt(0).toUpperCase() : '';
5
+ };
@@ -0,0 +1,38 @@
1
+ import { formatToShortMonthDateYear } from '../formatting/dateTime.ts';
2
+
3
+ export const formatProfileDateToShortMonthDateYear = formatToShortMonthDateYear;
4
+
5
+ export function formatProfileUserPhoneNumber(input: string | number): string | undefined {
6
+ const digits = input.toString().replace(/\D/g, '');
7
+
8
+ if (digits.length !== 11 || !digits.startsWith('1')) {
9
+ return undefined;
10
+ }
11
+
12
+ const areaCode = digits.slice(1, 4);
13
+ const centralOfficeCode = digits.slice(4, 7);
14
+ const lineNumber = digits.slice(7, 11);
15
+
16
+ return `+1 (${areaCode}) ${centralOfficeCode} - ${lineNumber}`;
17
+ }
18
+
19
+ export function formatProfilePhoneNumberForDisplay(
20
+ input?: string | number | null,
21
+ ): string | undefined {
22
+ if (input === undefined || input === null || input === '') return undefined;
23
+
24
+ const cleaned = String(input).replace(/\D/g, '');
25
+ let countryCode = '';
26
+ let number = cleaned;
27
+
28
+ if (cleaned.length > 10) {
29
+ countryCode = `+${cleaned.slice(0, cleaned.length - 10)} `;
30
+ number = cleaned.slice(-10);
31
+ }
32
+
33
+ const areaCode = number.slice(0, 3);
34
+ const middlePart = number.slice(3, 6);
35
+ const lastPart = number.slice(6);
36
+
37
+ return `${countryCode}(${areaCode}) ${middlePart}-${lastPart}`;
38
+ }
@@ -0,0 +1,45 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ vi,
6
+ } from 'vitest';
7
+ import { createFormatterCache } from '../formatterCache';
8
+
9
+ describe('formatterCache', () => {
10
+ it('reuses formatted values while item signatures are unchanged', () => {
11
+ const format = vi.fn((item: { id: number; value: string }) => item.value.toUpperCase());
12
+ const cache = createFormatterCache({
13
+ getKey: (item: { id: number }) => item.id,
14
+ getSignature: (item: { value: string }) => item.value,
15
+ format,
16
+ });
17
+
18
+ expect(cache.format({ id: 1, value: 'alpha' })).toBe('ALPHA');
19
+ expect(cache.format({ id: 1, value: 'alpha' })).toBe('ALPHA');
20
+ expect(format).toHaveBeenCalledTimes(1);
21
+
22
+ expect(cache.format({ id: 1, value: 'beta' })).toBe('BETA');
23
+ expect(format).toHaveBeenCalledTimes(2);
24
+ });
25
+
26
+ it('prunes old keys and clears the cache', () => {
27
+ const format = vi.fn((item: { id: number; value: string }) => item.value);
28
+ const cache = createFormatterCache({
29
+ getKey: (item: { id: number }) => item.id,
30
+ getSignature: (item: { value: string }) => item.value,
31
+ format,
32
+ });
33
+
34
+ cache.formatMany([
35
+ { id: 1, value: 'one' },
36
+ { id: 2, value: 'two' },
37
+ ]);
38
+ cache.prune([{ id: 1, value: 'one' }]);
39
+ cache.format({ id: 2, value: 'two' });
40
+ cache.clear();
41
+ cache.format({ id: 1, value: 'one' });
42
+
43
+ expect(format).toHaveBeenCalledTimes(4);
44
+ });
45
+ });
@@ -0,0 +1,56 @@
1
+ type FormatterCacheEntry<TOutput> = {
2
+ signature: string;
3
+ formatted: TOutput;
4
+ };
5
+
6
+ type FormatterCacheConfig<TInput, TOutput, TKey extends string | number> = {
7
+ getKey: (item: TInput) => TKey;
8
+ getSignature: (item: TInput) => string;
9
+ format: (item: TInput) => TOutput;
10
+ };
11
+
12
+ export const createFormatterCache = <
13
+ TInput,
14
+ TOutput,
15
+ TKey extends string | number = number,
16
+ >(
17
+ config: FormatterCacheConfig<TInput, TOutput, TKey>,
18
+ ) => {
19
+ const cache = new Map<TKey, FormatterCacheEntry<TOutput>>();
20
+
21
+ const format = (item: TInput): TOutput => {
22
+ const key = config.getKey(item);
23
+ const signature = config.getSignature(item);
24
+ const cached = cache.get(key);
25
+
26
+ if (cached && cached.signature === signature) {
27
+ return cached.formatted;
28
+ }
29
+
30
+ const formatted = config.format(item);
31
+ cache.set(key, { signature, formatted });
32
+ return formatted;
33
+ };
34
+
35
+ const formatMany = (items: TInput[]): TOutput[] => items.map(format);
36
+
37
+ const prune = (items: TInput[]) => {
38
+ const keys = new Set(items.map(config.getKey));
39
+ for (const key of cache.keys()) {
40
+ if (!keys.has(key)) {
41
+ cache.delete(key);
42
+ }
43
+ }
44
+ };
45
+
46
+ const clear = () => {
47
+ cache.clear();
48
+ };
49
+
50
+ return {
51
+ format,
52
+ formatMany,
53
+ prune,
54
+ clear,
55
+ };
56
+ };
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ deriveWalletAuthEmail,
4
+ getWalletAuthAddressFromDetails,
5
+ isRecoverableWalletAuthError,
6
+ isWalletBackendError,
7
+ isWalletBackendReady,
8
+ isUserRejectedWalletSignatureError,
9
+ normalizeWalletAuthOperationIntent,
10
+ resolveWalletAuthErrorMessage,
11
+ shouldPromptWalletAuth,
12
+ } from '../auth.ts';
13
+
14
+ describe('wallet auth helpers', () => {
15
+ it('uses the real email for individual profiles', () => {
16
+ expect(deriveWalletAuthEmail('User@Mail.com', 'Acme Holdings LLC', true)).toBe('user@mail.com');
17
+ });
18
+
19
+ it('derives a deterministic alias for non-individual profiles', () => {
20
+ expect(deriveWalletAuthEmail('user@mail.com', ' Acme Holdings, LLC ', false)).toBe(
21
+ 'user+acme-holdings-llc@mail.com',
22
+ );
23
+ });
24
+
25
+ it('treats created and verified statuses as backend-ready', () => {
26
+ expect(isWalletBackendReady('created')).toBe(true);
27
+ expect(isWalletBackendReady('verified')).toBe(true);
28
+ expect(isWalletBackendReady('error')).toBe(false);
29
+ });
30
+
31
+ it('recognizes explicit backend error statuses', () => {
32
+ expect(isWalletBackendError('error')).toBe(true);
33
+ expect(isWalletBackendError('error_retry')).toBe(true);
34
+ expect(isWalletBackendError('verified')).toBe(false);
35
+ });
36
+
37
+ it('prompts wallet auth only when KYC is approved and wallet is not ready', () => {
38
+ expect(shouldPromptWalletAuth({ isKycApproved: true, walletStatus: '' })).toBe(true);
39
+ expect(shouldPromptWalletAuth({ isKycApproved: true, walletStatus: 'verified' })).toBe(false);
40
+ expect(shouldPromptWalletAuth({ isKycApproved: true, walletStatus: 'error' })).toBe(false);
41
+ expect(shouldPromptWalletAuth({ isKycApproved: false, walletStatus: '' })).toBe(false);
42
+ });
43
+
44
+ it('extracts a normalized wallet address from signer auth details', () => {
45
+ expect(getWalletAuthAddressFromDetails({ address: ' 0xabc123 ' })).toBe('0xabc123');
46
+ expect(getWalletAuthAddressFromDetails({ address: '' })).toBe('');
47
+ expect(getWalletAuthAddressFromDetails(null)).toBe('');
48
+ });
49
+
50
+ it('normalizes wallet operation intent text without changing the operation shape', () => {
51
+ expect(normalizeWalletAuthOperationIntent({
52
+ source: 'exchange',
53
+ profileId: 7,
54
+ chain: ' sepolia ',
55
+ nonce: ' auth_1 ',
56
+ amount: ' 25.00 ',
57
+ assetAddress: ' 0xusdc ',
58
+ assetSymbol: ' USDC ',
59
+ toAssetAddress: ' 0xeth ',
60
+ toAssetSymbol: ' ETH ',
61
+ destinationAddress: ' 0xrecipient ',
62
+ estimatedReceiveText: ' 0.01 ETH ',
63
+ estimatedRateText: ' ',
64
+ })).toEqual({
65
+ source: 'exchange',
66
+ profileId: 7,
67
+ chain: 'sepolia',
68
+ nonce: 'auth_1',
69
+ amount: '25.00',
70
+ assetAddress: '0xusdc',
71
+ assetSymbol: 'USDC',
72
+ toAssetAddress: '0xeth',
73
+ toAssetSymbol: 'ETH',
74
+ destinationAddress: '0xrecipient',
75
+ estimatedReceiveText: '0.01 ETH',
76
+ });
77
+ });
78
+
79
+ it('classifies signer-auth errors as recoverable wallet-auth failures', () => {
80
+ expect(isRecoverableWalletAuthError(new Error('Wallet signer is not connected.'))).toBe(true);
81
+ expect(isRecoverableWalletAuthError(new Error('Iframe container cannot be found'))).toBe(true);
82
+ expect(isRecoverableWalletAuthError({
83
+ name: 'NotAuthenticatedError',
84
+ message: 'Signer not authenticated. Please authenticate to use this signer',
85
+ })).toBe(true);
86
+ expect(isRecoverableWalletAuthError(
87
+ new Error('Failed to sign: Key not initialized. Call init() first. Version: viem@2.54.1'),
88
+ )).toBe(true);
89
+ expect(isRecoverableWalletAuthError(new Error('User rejected signature'))).toBe(false);
90
+ });
91
+
92
+ it('detects explicit user-rejected signing errors', () => {
93
+ expect(isUserRejectedWalletSignatureError(new Error('User rejected the request.'))).toBe(true);
94
+ expect(isUserRejectedWalletSignatureError(new Error('Request rejected by backend.'))).toBe(false);
95
+ });
96
+
97
+ it('extracts the nested SDK message from structured wallet-auth errors', () => {
98
+ expect(resolveWalletAuthErrorMessage({
99
+ error: '{"code":3,"message":"Max number of OTPs have been initiated please wait and try again","details":[{"@type":"type.googleapis.com/errors.v1.TurnkeyErrorDetail","turnkeyErrorCode":"MAX_OTP_INITIATED"}],"turnkeyErrorCode":"MAX_OTP_INITIATED"}',
100
+ }, 'Fallback')).toBe('Max number of OTPs have been initiated please wait and try again');
101
+ });
102
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ buildChainExplorerAddressUrl,
4
+ buildChainExplorerTransactionUrl,
5
+ buildWalletAuthExplorerLink,
6
+ formatWalletAuthAddressValue,
7
+ formatWalletAuthAssetValue,
8
+ getWalletAuthExchangeTargetAssetAddress,
9
+ shortenWalletAuthOperationText,
10
+ } from '../operationPresentation.ts';
11
+
12
+ describe('wallet operation presentation helpers', () => {
13
+ it('builds transaction links from the deployment chain without a network default', () => {
14
+ expect(buildChainExplorerTransactionUrl('base', '0xabc'))
15
+ .toBe('https://basescan.org/tx/0xabc');
16
+ expect(buildChainExplorerTransactionUrl('Ethereum Sepolia', '0xdef'))
17
+ .toBe('https://sepolia.etherscan.io/tx/0xdef');
18
+ expect(buildChainExplorerTransactionUrl('unsupported', '0xdef')).toBeNull();
19
+ });
20
+
21
+ it('builds Vault address links only for mapped chains and valid addresses', () => {
22
+ const address = '0x1111111111111111111111111111111111111111';
23
+ expect(buildChainExplorerAddressUrl('base', address))
24
+ .toBe(`https://basescan.org/address/${address}`);
25
+ expect(buildChainExplorerAddressUrl('ethereum-sepolia', address))
26
+ .toBe(`https://sepolia.etherscan.io/address/${address}`);
27
+ expect(buildChainExplorerAddressUrl('unsupported', address)).toBeNull();
28
+ expect(buildChainExplorerAddressUrl('base', 'not-an-address')).toBeNull();
29
+ expect(buildChainExplorerAddressUrl('base', '')).toBeNull();
30
+ });
31
+
32
+ it('shortens long wallet operation text while leaving short text unchanged', () => {
33
+ expect(shortenWalletAuthOperationText(' 0x1234 ')).toBe('0x1234');
34
+ expect(shortenWalletAuthOperationText('0x1234567890abcdef1234567890abcdef12345678'))
35
+ .toBe('0x123456...345678');
36
+ });
37
+
38
+ it('formats addresses and asset labels for operation summaries', () => {
39
+ expect(formatWalletAuthAddressValue('0x1234567890abcdef')).toBe('0x1234567890abcdef');
40
+ expect(formatWalletAuthAddressValue('0x1234567890abcdef1234567890abcdef12345678'))
41
+ .toBe('0x123456...345678');
42
+ expect(formatWalletAuthAssetValue('USDC', '0x1234567890abcdef1234567890abcdef12345678'))
43
+ .toBe('USDC (0x123456...345678)');
44
+ expect(formatWalletAuthAssetValue('', '0x1234567890abcdef1234567890abcdef12345678'))
45
+ .toBe('0x123456...345678');
46
+ });
47
+
48
+ it('builds explorer links only when value and base URL are available', () => {
49
+ expect(buildWalletAuthExplorerLink({
50
+ baseUrl: 'https://scan.example.test/',
51
+ type: 'address',
52
+ value: '0xabc',
53
+ text: '0xabc',
54
+ ariaLabel: 'View wallet',
55
+ })).toEqual({
56
+ href: 'https://scan.example.test/address/0xabc',
57
+ text: '0xabc',
58
+ title: '0xabc',
59
+ ariaLabel: 'View wallet',
60
+ });
61
+ expect(buildWalletAuthExplorerLink({
62
+ baseUrl: '',
63
+ type: 'address',
64
+ value: '0xabc',
65
+ text: '0xabc',
66
+ ariaLabel: 'View wallet',
67
+ })).toBeNull();
68
+ });
69
+
70
+ it('returns target asset addresses only for exchange intents', () => {
71
+ expect(getWalletAuthExchangeTargetAssetAddress({
72
+ source: 'exchange',
73
+ profileId: 1,
74
+ chain: 'sepolia',
75
+ nonce: '1',
76
+ amount: '1',
77
+ assetAddress: '0xusdc',
78
+ assetSymbol: 'USDC',
79
+ toAssetAddress: '0xtarget',
80
+ toAssetSymbol: 'RWA',
81
+ destinationAddress: '0xdestination',
82
+ })).toBe('0xtarget');
83
+ expect(getWalletAuthExchangeTargetAssetAddress({
84
+ source: 'withdrawal',
85
+ profileId: 1,
86
+ chain: 'sepolia',
87
+ nonce: '1',
88
+ amount: '1',
89
+ assetAddress: '0xusdc',
90
+ assetSymbol: 'USDC',
91
+ destinationAddress: '0xdestination',
92
+ })).toBe('');
93
+ });
94
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isWalletSetupRequiredError } from '../setupError.ts';
3
+
4
+ describe('wallet setup error helpers', () => {
5
+ it('treats 404 wallet lookups for approved profiles as setup required', () => {
6
+ expect(isWalletSetupRequiredError(
7
+ { data: { statusCode: 404 } },
8
+ { isKycApproved: true, walletData: undefined },
9
+ )).toBe(true);
10
+ });
11
+
12
+ it('treats known missing-wallet backend messages as setup required', () => {
13
+ expect(isWalletSetupRequiredError(
14
+ new Error('User need to create wallet'),
15
+ { isKycApproved: true, walletData: undefined },
16
+ )).toBe(true);
17
+ });
18
+
19
+ it('treats incomplete wallet registration as setup required', () => {
20
+ expect(isWalletSetupRequiredError(
21
+ new Error('wallet registration is incomplete for ethereum-sepolia'),
22
+ { isKycApproved: true, walletData: undefined },
23
+ )).toBe(true);
24
+ });
25
+
26
+ it('does not treat generic backend failures as setup required', () => {
27
+ expect(isWalletSetupRequiredError(
28
+ { data: { statusCode: 500, responseJson: { message: 'Upstream error' } } },
29
+ { isKycApproved: true, walletData: undefined },
30
+ )).toBe(false);
31
+ });
32
+ });