@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,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ assertCanonicalDecimalString,
4
+ assertPositiveCanonicalDecimalString,
5
+ canonicalDecimalToScaled,
6
+ compareCanonicalDecimals,
7
+ formatCanonicalDecimal,
8
+ formatCanonicalUsd,
9
+ formatExactUsd,
10
+ isCanonicalDecimalString,
11
+ multiplyCanonicalDecimals,
12
+ scaledToCanonicalDecimal,
13
+ } from '../canonicalDecimal.ts';
14
+
15
+ describe('canonical decimals', () => {
16
+ it('accepts only the numeric(38,18) canonical JSON string contract', () => {
17
+ expect(isCanonicalDecimalString('0')).toBe(true);
18
+ expect(isCanonicalDecimalString('99999999999999999999.999999999999999999')).toBe(true);
19
+ expect(isCanonicalDecimalString('0.000000000000000001')).toBe(true);
20
+
21
+ for (const invalid of [0, '00', '01', '1.0', '1.', '.1', '-1', '+1', '1e2',
22
+ '100000000000000000000', '0.0000000000000000001']) {
23
+ expect(isCanonicalDecimalString(invalid)).toBe(false);
24
+ }
25
+ });
26
+
27
+ it('rejects numeric tokens rather than coercing them', () => {
28
+ expect(() => assertCanonicalDecimalString(1, 'number_of_shares'))
29
+ .toThrow('number_of_shares must be a canonical decimal string');
30
+ });
31
+
32
+ it('enforces positive business values separately from the zero-capable wire format', () => {
33
+ expect(() => assertPositiveCanonicalDecimalString('0', 'number_of_shares'))
34
+ .toThrow('number_of_shares must be greater than zero');
35
+ expect(() => assertPositiveCanonicalDecimalString(1, 'number_of_shares'))
36
+ .toThrow('number_of_shares must be a canonical decimal string');
37
+ expect(() => assertPositiveCanonicalDecimalString('0.000000000000000001'))
38
+ .not.toThrow();
39
+ });
40
+
41
+ it('converts and compares values without IEEE-754 precision loss', () => {
42
+ const maximum = '99999999999999999999.999999999999999999';
43
+ expect(scaledToCanonicalDecimal(canonicalDecimalToScaled(maximum))).toBe(maximum);
44
+ expect(compareCanonicalDecimals('9007199254740993', '9007199254740992.999999999999999999')).toBe(1);
45
+ });
46
+
47
+ it('multiplies and formats through scaled integers', () => {
48
+ expect(multiplyCanonicalDecimals('12.5', '2000.000000000000000001'))
49
+ .toBe('25000.0000000000000000125');
50
+ expect(formatExactUsd(multiplyCanonicalDecimals('0.005', '1'), 2)).toBe('$0.01');
51
+ expect(multiplyCanonicalDecimals(
52
+ '99999999999999999999.999999999999999999',
53
+ '99999999999999999999.999999999999999999',
54
+ )).toBe('9999999999999999999999999999999999999800.000000000000000000000000000000000001');
55
+ expect(formatCanonicalDecimal('9007199254740993.125')).toBe('9,007,199,254,740,993.125');
56
+ expect(formatCanonicalUsd('9007199254740993.125')).toBe('$9,007,199,254,740,993.13');
57
+ });
58
+ });
@@ -0,0 +1,154 @@
1
+ export const CANONICAL_DECIMAL_PATTERN = /^(?:0|[1-9][0-9]{0,19})(?:\.[0-9]{0,17}[1-9])?$/u;
2
+
3
+ export const CANONICAL_DECIMAL_SCALE = 18;
4
+ export const MAX_CANONICAL_DECIMAL = '99999999999999999999.999999999999999999';
5
+ const SCALE_FACTOR = 10n ** BigInt(CANONICAL_DECIMAL_SCALE);
6
+ const EXACT_DECIMAL_PATTERN = /^(?:0|[1-9][0-9]*)(?:\.[0-9]*[1-9])?$/u;
7
+
8
+ export function isCanonicalDecimalString(value: unknown): value is string {
9
+ return typeof value === 'string' && CANONICAL_DECIMAL_PATTERN.test(value);
10
+ }
11
+
12
+ export function assertCanonicalDecimalString(
13
+ value: unknown,
14
+ fieldName = 'decimal value',
15
+ ): asserts value is string {
16
+ if (!isCanonicalDecimalString(value)) {
17
+ throw new TypeError(`${fieldName} must be a canonical decimal string`);
18
+ }
19
+ }
20
+
21
+ export function assertPositiveCanonicalDecimalString(
22
+ value: unknown,
23
+ fieldName = 'decimal value',
24
+ ): asserts value is string {
25
+ assertCanonicalDecimalString(value, fieldName);
26
+ if (value === '0') {
27
+ throw new RangeError(`${fieldName} must be greater than zero`);
28
+ }
29
+ }
30
+
31
+ export function canonicalDecimalToScaled(value: string): bigint {
32
+ assertCanonicalDecimalString(value);
33
+ const [whole = '0', fraction = ''] = value.split('.');
34
+ return BigInt(whole) * SCALE_FACTOR
35
+ + BigInt(fraction.padEnd(CANONICAL_DECIMAL_SCALE, '0') || '0');
36
+ }
37
+
38
+ export function scaledToCanonicalDecimal(value: bigint): string {
39
+ if (value < 0n) {
40
+ throw new RangeError('canonical decimal values cannot be negative');
41
+ }
42
+
43
+ const whole = value / SCALE_FACTOR;
44
+ const fraction = (value % SCALE_FACTOR)
45
+ .toString()
46
+ .padStart(CANONICAL_DECIMAL_SCALE, '0')
47
+ .replace(/0+$/u, '');
48
+ const result = fraction ? `${whole}.${fraction}` : whole.toString();
49
+ assertCanonicalDecimalString(result);
50
+ return result;
51
+ }
52
+
53
+ export function compareCanonicalDecimals(left: string, right: string): number {
54
+ const leftScaled = canonicalDecimalToScaled(left);
55
+ const rightScaled = canonicalDecimalToScaled(right);
56
+ if (leftScaled === rightScaled) return 0;
57
+ return leftScaled < rightScaled ? -1 : 1;
58
+ }
59
+
60
+ export function multiplyCanonicalDecimals(left: string, right: string): string {
61
+ const product = canonicalDecimalToScaled(left) * canonicalDecimalToScaled(right);
62
+ const productScale = CANONICAL_DECIMAL_SCALE * 2;
63
+ const factor = 10n ** BigInt(productScale);
64
+ const whole = product / factor;
65
+ const fraction = (product % factor)
66
+ .toString()
67
+ .padStart(productScale, '0')
68
+ .replace(/0+$/u, '');
69
+ return fraction ? `${whole}.${fraction}` : whole.toString();
70
+ }
71
+
72
+ type CanonicalDecimalFormatOptions = {
73
+ minimumFractionDigits?: number;
74
+ maximumFractionDigits?: number;
75
+ useGrouping?: boolean;
76
+ };
77
+
78
+ export function formatCanonicalDecimal(
79
+ value: string,
80
+ options: CanonicalDecimalFormatOptions = {},
81
+ ): string {
82
+ let scaled = canonicalDecimalToScaled(value);
83
+ const minimumFractionDigits = options.minimumFractionDigits ?? 0;
84
+ const maximumFractionDigits = options.maximumFractionDigits ?? CANONICAL_DECIMAL_SCALE;
85
+
86
+ if (
87
+ !Number.isSafeInteger(minimumFractionDigits)
88
+ || !Number.isSafeInteger(maximumFractionDigits)
89
+ || minimumFractionDigits < 0
90
+ || maximumFractionDigits > CANONICAL_DECIMAL_SCALE
91
+ || minimumFractionDigits > maximumFractionDigits
92
+ ) {
93
+ throw new RangeError('invalid canonical decimal formatting precision');
94
+ }
95
+
96
+ const discardedDigits = CANONICAL_DECIMAL_SCALE - maximumFractionDigits;
97
+ if (discardedDigits > 0) {
98
+ const divisor = 10n ** BigInt(discardedDigits);
99
+ const quotient = scaled / divisor;
100
+ const remainder = scaled % divisor;
101
+ scaled = (quotient + (remainder * 2n >= divisor ? 1n : 0n)) * divisor;
102
+ }
103
+
104
+ const whole = scaled / SCALE_FACTOR;
105
+ const groupedWhole = options.useGrouping === false
106
+ ? whole.toString()
107
+ : whole.toString().replace(/\B(?=(\d{3})+(?!\d))/gu, ',');
108
+ let fraction = (scaled % SCALE_FACTOR)
109
+ .toString()
110
+ .padStart(CANONICAL_DECIMAL_SCALE, '0')
111
+ .slice(0, maximumFractionDigits);
112
+
113
+ while (fraction.length > minimumFractionDigits && fraction.endsWith('0')) {
114
+ fraction = fraction.slice(0, -1);
115
+ }
116
+
117
+ return fraction ? `${groupedWhole}.${fraction}` : groupedWhole;
118
+ }
119
+
120
+ export function formatCanonicalUsd(value: string, fractionDigits = 2): string {
121
+ return `$${formatCanonicalDecimal(value, {
122
+ minimumFractionDigits: fractionDigits,
123
+ maximumFractionDigits: fractionDigits,
124
+ })}`;
125
+ }
126
+
127
+ /** Format a nonnegative exact decimal (including a scale-36 product) with one rounding step. */
128
+ export function formatExactUsd(value: string, fractionDigits = 2): string {
129
+ if (!EXACT_DECIMAL_PATTERN.test(value)) {
130
+ throw new TypeError('exact decimal value must be a nonnegative canonical decimal string');
131
+ }
132
+ if (!Number.isSafeInteger(fractionDigits) || fractionDigits < 0 || fractionDigits > 36) {
133
+ throw new RangeError('invalid exact decimal formatting precision');
134
+ }
135
+
136
+ const [wholePart, fractionPart = ''] = value.split('.');
137
+ let unscaled = BigInt(`${wholePart}${fractionPart}`);
138
+ if (fractionPart.length > fractionDigits) {
139
+ const divisor = 10n ** BigInt(fractionPart.length - fractionDigits);
140
+ const quotient = unscaled / divisor;
141
+ const remainder = unscaled % divisor;
142
+ unscaled = quotient + (remainder * 2n >= divisor ? 1n : 0n);
143
+ }
144
+ else if (fractionPart.length < fractionDigits) {
145
+ unscaled *= 10n ** BigInt(fractionDigits - fractionPart.length);
146
+ }
147
+
148
+ const factor = 10n ** BigInt(fractionDigits);
149
+ const whole = fractionDigits === 0 ? unscaled : unscaled / factor;
150
+ const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/gu, ',');
151
+ if (fractionDigits === 0) return `$${grouped}`;
152
+ const fraction = (unscaled % factor).toString().padStart(fractionDigits, '0');
153
+ return `$${grouped}.${fraction}`;
154
+ }
@@ -0,0 +1,488 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ calculateEvmWalletFundingBalance,
4
+ calculateEvmWalletRwaValue,
5
+ EvmWalletStatusTypes,
6
+ extractDepositAddressFromWalletInfo,
7
+ isEvmWalletLegacyResponse,
8
+ normalizeEvmWalletInfoResponse,
9
+ } from '../walletInfo.ts';
10
+
11
+ describe('walletInfo', () => {
12
+ it('keeps the legacy wallet info payload unchanged', () => {
13
+ const payload = {
14
+ id: 10,
15
+ status: EvmWalletStatusTypes.verified,
16
+ balance: '123.45',
17
+ inc_balance: 1,
18
+ out_balance: 2,
19
+ address: '0xabc',
20
+ balances: {},
21
+ transactions: [],
22
+ created_at: '2026-04-08T17:35:45Z',
23
+ updated_at: '2026-04-08T17:35:45Z',
24
+ };
25
+
26
+ expect(isEvmWalletLegacyResponse(payload)).toBe(true);
27
+ expect(normalizeEvmWalletInfoResponse(payload)).toEqual(payload);
28
+ });
29
+
30
+ it('normalizes the new status-only wallet info payload into the legacy wallet shape', () => {
31
+ const payload = {
32
+ profile_id: 1124,
33
+ wallet_status: 'created',
34
+ provider_name: 'turnkey',
35
+ chain: 'ethereum-sepolia',
36
+ wallet_address: '0xwallet',
37
+ chain_account_status: 'verified',
38
+ deposit_instructions: {
39
+ chain: 'ethereum-sepolia',
40
+ address: '0xdeposit',
41
+ },
42
+ chains: [
43
+ { chain: 'ethereum', wallet_address: '', chain_account_status: 'pending' },
44
+ { chain: 'polygon', wallet_address: '', chain_account_status: 'pending' },
45
+ ],
46
+ updated_at: '2026-04-08T17:35:45Z',
47
+ };
48
+
49
+ expect(normalizeEvmWalletInfoResponse(payload)).toEqual({
50
+ id: 1124,
51
+ status: EvmWalletStatusTypes.created,
52
+ provider_name: 'turnkey',
53
+ balance: '0',
54
+ inc_balance: 0,
55
+ out_balance: 0,
56
+ address: '0xwallet',
57
+ chain: 'ethereum-sepolia',
58
+ deposit_instructions: {
59
+ chain: 'ethereum-sepolia',
60
+ address: '0xdeposit',
61
+ },
62
+ chains: [
63
+ { chain: 'ethereum', wallet_address: '', chain_account_status: 'pending' },
64
+ { chain: 'polygon', wallet_address: '', chain_account_status: 'pending' },
65
+ { chain: 'ethereum-sepolia', wallet_address: '0xwallet', chain_account_status: 'verified' },
66
+ ],
67
+ balances: {},
68
+ transactions: [],
69
+ created_at: '2026-04-08T17:35:45Z',
70
+ updated_at: '2026-04-08T17:35:45Z',
71
+ });
72
+ });
73
+
74
+ it('preserves profile id while exposing backend wallet_id for exchange consumers', () => {
75
+ const payload = {
76
+ profile_id: 1124,
77
+ wallet_id: 44,
78
+ wallet_status: 'verified',
79
+ provider_name: 'turnkey',
80
+ chain: 'ethereum-sepolia',
81
+ wallet_address: '0xwallet',
82
+ updated_at: '2026-04-08T17:35:45Z',
83
+ };
84
+
85
+ expect(normalizeEvmWalletInfoResponse(payload)).toEqual(expect.objectContaining({
86
+ id: 1124,
87
+ wallet_id: 44,
88
+ status: EvmWalletStatusTypes.verified,
89
+ }));
90
+ });
91
+
92
+ it('keeps top-level chain wallet fields when backend returns an empty chains list', () => {
93
+ const payload = {
94
+ profile_id: 1129,
95
+ wallet_status: 'created',
96
+ chain: 'ethereum-sepolia',
97
+ wallet_address: '0x51da1389112a99a972b248c0510a77a9731a475b',
98
+ chain_account_status: 'verified',
99
+ balances: [],
100
+ deposit_instructions: {
101
+ chain: 'ethereum-sepolia',
102
+ address: '0x51da1389112a99a972b248c0510a77a9731a475b',
103
+ },
104
+ chains: [],
105
+ updated_at: '2026-04-10T09:59:58Z',
106
+ };
107
+
108
+ expect(normalizeEvmWalletInfoResponse(payload)).toEqual({
109
+ id: 1129,
110
+ status: EvmWalletStatusTypes.created,
111
+ balance: '0',
112
+ inc_balance: 0,
113
+ out_balance: 0,
114
+ address: '0x51da1389112a99a972b248c0510a77a9731a475b',
115
+ chain: 'ethereum-sepolia',
116
+ deposit_instructions: {
117
+ chain: 'ethereum-sepolia',
118
+ address: '0x51da1389112a99a972b248c0510a77a9731a475b',
119
+ },
120
+ chains: [
121
+ {
122
+ chain: 'ethereum-sepolia',
123
+ wallet_address: '0x51da1389112a99a972b248c0510a77a9731a475b',
124
+ chain_account_status: 'verified',
125
+ },
126
+ ],
127
+ balances: {},
128
+ transactions: [],
129
+ created_at: '2026-04-10T09:59:58Z',
130
+ updated_at: '2026-04-10T09:59:58Z',
131
+ });
132
+ });
133
+
134
+ it('falls back to the first available chain wallet address', () => {
135
+ const payload = {
136
+ profile_id: 1124,
137
+ wallet_status: 'verified',
138
+ chains: [
139
+ { chain: 'ethereum', wallet_address: '', chain_account_status: 'verified' },
140
+ { chain: 'base', wallet_address: '0xbase', chain_account_status: 'verified' },
141
+ ],
142
+ updated_at: '2026-04-08T17:35:45Z',
143
+ };
144
+
145
+ expect(normalizeEvmWalletInfoResponse(payload).address).toBe('0xbase');
146
+ });
147
+
148
+ it('keeps normalized chain addresses for downstream network selection', () => {
149
+ const payload = {
150
+ profile_id: 1124,
151
+ wallet_status: 'verified',
152
+ chains: [
153
+ { chain: 'ethereum', wallet_address: '0xeth', chain_account_status: 'verified' },
154
+ { chain: 'base', wallet_address: '0xbase', chain_account_status: 'verified' },
155
+ ],
156
+ updated_at: '2026-04-08T17:35:45Z',
157
+ };
158
+
159
+ expect(normalizeEvmWalletInfoResponse(payload).chains).toEqual([
160
+ { chain: 'ethereum', wallet_address: '0xeth', chain_account_status: 'verified' },
161
+ { chain: 'base', wallet_address: '0xbase', chain_account_status: 'verified' },
162
+ ]);
163
+ });
164
+
165
+ it('normalizes balances from the new wallet info payload shape', () => {
166
+ const payload = {
167
+ profile_id: 1124,
168
+ wallet_status: 'verified',
169
+ balances: [
170
+ { asset: 'USDC', address: '0xusdc', amount: '10.5' },
171
+ { symbol: 'ETH', address: '0xeth', amount: '1.25' },
172
+ ],
173
+ updated_at: '2026-04-08T17:35:45Z',
174
+ };
175
+
176
+ expect(normalizeEvmWalletInfoResponse(payload).balances).toEqual({
177
+ '0xusdc': {
178
+ asset: 'USDC',
179
+ address: '0xusdc',
180
+ amount: '10.5',
181
+ symbol: 'USDC',
182
+ name: 'USDC',
183
+ },
184
+ '0xeth': {
185
+ asset: 'ETH',
186
+ address: '0xeth',
187
+ amount: '1.25',
188
+ symbol: 'ETH',
189
+ name: 'ETH',
190
+ },
191
+ });
192
+ });
193
+
194
+ it('uses token metadata fields for normalized balance display fields', () => {
195
+ const payload = {
196
+ profile_id: 1124,
197
+ wallet_status: 'verified',
198
+ balances: [
199
+ {
200
+ asset: '',
201
+ asset_ticker: '',
202
+ token_address: '0xtoken',
203
+ token_name: 'City of Springfield 2025 Infrastructure Improvement Bond',
204
+ token_symbol: 'City of Spr',
205
+ token_logo: 'https://assets.test/city-of-spr.png',
206
+ price_per_usd: '12.5',
207
+ amount_usd: '12512.50',
208
+ amount: '1001.000000000000000000',
209
+ },
210
+ ],
211
+ updated_at: '2026-04-08T17:35:45Z',
212
+ };
213
+
214
+ expect(normalizeEvmWalletInfoResponse(payload).balances).toEqual({
215
+ '0xtoken': {
216
+ asset: 'City of Spr',
217
+ address: '0xtoken',
218
+ amount: '1001.000000000000000000',
219
+ symbol: 'City of Spr',
220
+ name: 'City of Springfield 2025 Infrastructure Improvement Bond',
221
+ icon: 'https://assets.test/city-of-spr.png',
222
+ price_per_usd: 12.5,
223
+ price_per_usd_raw: '12.5',
224
+ amount_usd: 12512.5,
225
+ },
226
+ });
227
+ });
228
+
229
+ it('normalizes current wallet balance response fields', () => {
230
+ const payload = {
231
+ wallet_status: 'created',
232
+ chain: 'ethereum-sepolia',
233
+ wallet_address: '0x4138b9d0897fc7c896a0294698c06a2002047178',
234
+ chain_account_status: 'verified',
235
+ tradable_crypto_balance: { amount_usd: '93.80', token_count: 2 },
236
+ rwa_asset_balance: { amount_usd: '1375.00', token_count: 2 },
237
+ balances: [
238
+ {
239
+ token_address: '0x764db4a08b4dfbe6fc36543ac59496b18613b0d7',
240
+ token_name: 'VTest Series B',
241
+ token_symbol: 'VSB',
242
+ token_logo: 'https://assets.test/vsb.png',
243
+ token_decimals: 18,
244
+ token_standard: 'erc20',
245
+ chain: 'ethereum-sepolia',
246
+ is_native: false,
247
+ amount: '125.000000000000000000',
248
+ amount_usd: '1250',
249
+ },
250
+ ],
251
+ updated_at: '2026-06-02T16:34:00Z',
252
+ };
253
+
254
+ const normalized = normalizeEvmWalletInfoResponse(payload);
255
+
256
+ expect(normalized.balances).toEqual({
257
+ '0x764db4a08b4dfbe6fc36543ac59496b18613b0d7': {
258
+ asset: 'VSB',
259
+ address: '0x764db4a08b4dfbe6fc36543ac59496b18613b0d7',
260
+ amount: '125.000000000000000000',
261
+ symbol: 'VSB',
262
+ name: 'VTest Series B',
263
+ icon: 'https://assets.test/vsb.png',
264
+ chain: 'ethereum-sepolia',
265
+ token_decimals: 18,
266
+ token_standard: 'erc20',
267
+ is_native: false,
268
+ amount_usd: 1250,
269
+ },
270
+ });
271
+ expect(normalized.tradable_crypto_balance).toEqual({ amount_usd: '93.80', token_count: 2 });
272
+ expect(normalized.rwa_asset_balance).toEqual({ amount_usd: '1375.00', token_count: 2 });
273
+ });
274
+
275
+ it('preserves exchange eligibility metadata and exact quote inputs from a real wallet payload', () => {
276
+ const payload = {
277
+ profile_id: 1225,
278
+ wallet_id: 41,
279
+ wallet_status: 'verified',
280
+ provider_name: 'turnkey',
281
+ chain: 'ethereum-sepolia',
282
+ wallet_address: '0x51da1389112a99a972b248c0510a77a9731a475b',
283
+ chain_account_status: 'verified',
284
+ exchange_payout_token_address: '0xe2ccb3fc0153584e5c70c65849078b55597b4032',
285
+ exchange_payout_token_symbol: 'USDC',
286
+ chains: [{
287
+ chain: 'ethereum-sepolia',
288
+ wallet_address: '0x51da1389112a99a972b248c0510a77a9731a475b',
289
+ chain_account_status: 'verified',
290
+ exchange_payout_token_address: '0xe2ccb3fc0153584e5c70c65849078b55597b4032',
291
+ exchange_payout_token_symbol: 'USDC',
292
+ }],
293
+ balances: [{
294
+ token_address: '0x2d64b6451f0549f9b98542d74a62b956d27ae3e1',
295
+ offer_id: 168,
296
+ token_name: 'Test Loan LLC',
297
+ token_symbol: 'TLLC',
298
+ token_decimals: 18,
299
+ token_standard: 'erc20',
300
+ chain: 'ethereum-sepolia',
301
+ balance_type: 'rwa_asset' as const,
302
+ exchange_eligible: true,
303
+ amount: '10.123456789012345678',
304
+ price_usd: '7.161500000000000001',
305
+ }],
306
+ updated_at: '2026-07-11T08:00:00Z',
307
+ };
308
+
309
+ const normalized = normalizeEvmWalletInfoResponse(payload);
310
+
311
+ expect(normalized).toEqual(expect.objectContaining({
312
+ id: 1225,
313
+ wallet_id: 41,
314
+ chain: 'ethereum-sepolia',
315
+ exchange_payout_token_address: '0xe2ccb3fc0153584e5c70c65849078b55597b4032',
316
+ exchange_payout_token_symbol: 'USDC',
317
+ chains: [expect.objectContaining({
318
+ chain: 'ethereum-sepolia',
319
+ exchange_payout_token_address: '0xe2ccb3fc0153584e5c70c65849078b55597b4032',
320
+ })],
321
+ }));
322
+ expect(normalized.balances['0x2d64b6451f0549f9b98542d74a62b956d27ae3e1'])
323
+ .toEqual(expect.objectContaining({
324
+ offer_id: 168,
325
+ balance_type: 'rwa_asset',
326
+ exchange_eligible: true,
327
+ chain: 'ethereum-sepolia',
328
+ amount: '10.123456789012345678',
329
+ price_per_usd_raw: '7.161500000000000001',
330
+ }));
331
+ });
332
+
333
+ it('reconciles the configured stable coin when provider metadata is unknown', () => {
334
+ const stableCoinAddress = '0xe2ccb3fc0153584e5c70c65849078b55597b4032';
335
+ const payload = {
336
+ profile_id: 1230,
337
+ wallet_status: 'verified',
338
+ chain: 'ethereum-sepolia',
339
+ wallet_address: '0x51da1389112a99a972b248c0510a77a9731a475b',
340
+ balances: [{
341
+ token_address: stableCoinAddress,
342
+ token_name: 'Unknown Token',
343
+ token_symbol: stableCoinAddress,
344
+ token_decimals: 18,
345
+ token_standard: 'erc20',
346
+ chain: 'ethereum-sepolia',
347
+ amount: '25000',
348
+ price_usd: '0',
349
+ amount_usd: '0',
350
+ }],
351
+ tradable_crypto_balance: {
352
+ amount_usd: '0.00',
353
+ token_count: 1,
354
+ },
355
+ updated_at: '2026-07-25T12:00:00Z',
356
+ };
357
+
358
+ const normalized = normalizeEvmWalletInfoResponse(payload, {
359
+ stableCoinAddress,
360
+ stableCoinSymbol: 'USDC',
361
+ });
362
+
363
+ expect(normalized.balances[stableCoinAddress]).toEqual(expect.objectContaining({
364
+ address: stableCoinAddress,
365
+ asset: 'USDC',
366
+ symbol: 'USDC',
367
+ name: 'USDC',
368
+ amount: '25000',
369
+ price_per_usd: 1,
370
+ price_per_usd_raw: '1',
371
+ amount_usd: 25000,
372
+ balance_type: 'tradable_crypto',
373
+ }));
374
+ expect(normalized.tradable_crypto_balance).toEqual({
375
+ amount_usd: '25000',
376
+ token_count: 1,
377
+ });
378
+ expect(calculateEvmWalletFundingBalance(normalized)).toBe(25000);
379
+ });
380
+
381
+ it('prefers deposit instructions address when present', () => {
382
+ const payload = {
383
+ profile_id: 1124,
384
+ wallet_status: 'verified',
385
+ deposit_instructions: {
386
+ chain: 'ethereum',
387
+ address: '0xinstructions',
388
+ },
389
+ balances: [
390
+ { asset: 'USDC', address: '0xusdc-balance', amount: '0' },
391
+ ],
392
+ updated_at: '2026-04-08T17:35:45Z',
393
+ };
394
+
395
+ expect(extractDepositAddressFromWalletInfo(payload)).toBe('0xinstructions');
396
+ });
397
+
398
+ it('falls back to the USDC balance address when deposit instructions are empty', () => {
399
+ const payload = {
400
+ profile_id: 1124,
401
+ wallet_status: 'verified',
402
+ chain: 'ethereum',
403
+ deposit_instructions: {
404
+ chain: 'ethereum',
405
+ address: '',
406
+ },
407
+ balances: [
408
+ { asset: 'ETH', address: '0xeth-balance', amount: '1' },
409
+ { asset: 'USDC', address: '0xusdc-balance', amount: '0' },
410
+ ],
411
+ updated_at: '2026-04-08T17:35:45Z',
412
+ };
413
+
414
+ expect(extractDepositAddressFromWalletInfo(payload)).toBe('0xusdc-balance');
415
+ });
416
+
417
+ it('falls back to token_address when extracting a USDC deposit address', () => {
418
+ const payload = {
419
+ profile_id: 1124,
420
+ wallet_status: 'verified',
421
+ balances: [
422
+ { token_symbol: 'USDC', token_address: '0xusdc-token', amount: '0' },
423
+ ],
424
+ updated_at: '2026-04-08T17:35:45Z',
425
+ };
426
+
427
+ expect(extractDepositAddressFromWalletInfo(payload)).toBe('0xusdc-token');
428
+ });
429
+
430
+ it('uses wallet balance summaries before token-level fallback calculations', () => {
431
+ const balances = [
432
+ { address: '0xusdc', amount: '10', symbol: 'USDC' },
433
+ { address: '0xrwa', amount: '2', symbol: 'RWA', price_per_usd: 50 },
434
+ ];
435
+
436
+ expect(calculateEvmWalletFundingBalance({
437
+ balances,
438
+ tradable_crypto_balance: { amount_usd: '93.80', token_count: 2 },
439
+ })).toBe(93.8);
440
+ expect(calculateEvmWalletRwaValue({
441
+ balances,
442
+ rwa_asset_balance: { amount_usd: '1375.00', token_count: 2 },
443
+ })).toBe(1375);
444
+ });
445
+
446
+ it('uses the exact payout-token amount for funding even when its USD valuation is zero', () => {
447
+ expect(calculateEvmWalletFundingBalance({
448
+ exchange_payout_token_address: '0xUsdc',
449
+ exchange_payout_token_symbol: 'USDC',
450
+ balances: [
451
+ { address: '0xusdc', amount: '100', symbol: 'USDC' },
452
+ { address: '0xeth', amount: '2', symbol: 'ETH' },
453
+ ],
454
+ tradable_crypto_balance: { amount_usd: '0.00', token_count: 2 },
455
+ })).toBe(100);
456
+ });
457
+
458
+ it('calculates funding balance from stablecoin balances when no summary exists', () => {
459
+ expect(calculateEvmWalletFundingBalance({
460
+ balances: {
461
+ '0xusdc': { address: '0xusdc', amount: '10.5', symbol: 'USDC' },
462
+ '0xdai': { address: '0xdai', amount: 4, symbol: 'DAI' },
463
+ '0xrwa': { address: '0xrwa', amount: 2, symbol: 'RWA', price_per_usd: 50 },
464
+ },
465
+ })).toBe(14.5);
466
+ });
467
+
468
+ it('calculates RWA value from non-stablecoin balances when no summary exists', () => {
469
+ expect(calculateEvmWalletRwaValue({
470
+ balances: [
471
+ { address: '0xusdc', amount: '10.5', symbol: 'USDC', price_per_usd: 1 },
472
+ { address: '0xbond', amount: '3', symbol: 'BOND', price_per_usd: 100 },
473
+ { address: '0xnote', amount: 2, symbol: 'NOTE', price_per_usd: 25 },
474
+ ],
475
+ })).toBe(350);
476
+ });
477
+
478
+ it('falls back to token balances for invalid summaries and returns zero for empty inputs', () => {
479
+ expect(calculateEvmWalletFundingBalance({
480
+ tradable_crypto_balance: { amount_usd: 'not-a-number', token_count: 1 },
481
+ balances: [
482
+ { address: '0xusdc', amount: '7', symbol: 'USDC' },
483
+ ],
484
+ })).toBe(7);
485
+ expect(calculateEvmWalletFundingBalance({})).toBe(0);
486
+ expect(calculateEvmWalletRwaValue({})).toBe(0);
487
+ });
488
+ });