Skip to content

Commit cca3b59

Browse files
authored
Merge pull request #163 from akordavid373/fix/accuracy-collision-magic-numbers
Fix: Weekly to monthly calculation accuracy, ID collision risk, and m…
2 parents d1e7844 + 93983c5 commit cca3b59

5 files changed

Lines changed: 188 additions & 47 deletions

File tree

src/services/walletService.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { Framework, SFError } from '@superfluid-finance/sdk-core';
33

44
import { ERC20__factory, getContractAddress } from '../contracts';
55
import { getEvmRpcUrl } from '../config/evm';
6+
import {
7+
TIME_CONSTANTS,
8+
CRYPTO_CONSTANTS,
9+
CHAIN_IDS,
10+
ADDRESS_CONSTANTS,
11+
} from '../utils/constants/values';
612

713
export interface WalletConnection {
814
address: string;
@@ -44,7 +50,7 @@ export interface SuperfluidStreamResult {
4450
streamId: string;
4551
}
4652

47-
const SECONDS_PER_MONTH = 30 * 24 * 60 * 60;
53+
const SECONDS_PER_MONTH = TIME_CONSTANTS.SECONDS_PER_MONTH;
4854

4955
function isUserRejectedError(error: unknown): boolean {
5056
if (error == null || typeof error !== 'object') return false;
@@ -59,7 +65,7 @@ function superTokenResolverSymbol(chainId: number, tokenSymbol: string): string
5965
if (s === 'USDC' || s === 'USDC.E') return 'USDCx';
6066
if (s === 'MATIC') return 'MATICx';
6167
if (s === 'ETH') {
62-
if (chainId === 137) return 'MATICx';
68+
if (chainId === CHAIN_IDS.POLYGON) return 'MATICx';
6369
return 'ETHx';
6470
}
6571
if (s === 'ARB') {
@@ -154,11 +160,11 @@ export class WalletServiceManager {
154160
name: this.getNativeName(chainId),
155161
address: '0x0000000000000000000000000000000000000000',
156162
balance: ethers.utils.formatEther(nativeBalance),
157-
decimals: 18,
163+
decimals: CRYPTO_CONSTANTS.ETH_DECIMALS,
158164
});
159165

160166
// Get USDC balance if on supported chains
161-
if (chainId === 1 || chainId === 137 || chainId === 42161) {
167+
if (chainId === CHAIN_IDS.ETHEREUM || chainId === CHAIN_IDS.POLYGON || chainId === CHAIN_IDS.ARBITRUM) {
162168
const usdcAddress = getContractAddress(chainId, 'usdc');
163169
if (!usdcAddress) {
164170
return balances;
@@ -171,8 +177,8 @@ export class WalletServiceManager {
171177
symbol: 'USDC',
172178
name: 'USD Coin',
173179
address: usdcAddress,
174-
balance: ethers.utils.formatUnits(usdcBalance, 6),
175-
decimals: 6,
180+
balance: ethers.utils.formatUnits(usdcBalance, CRYPTO_CONSTANTS.USDC_DECIMALS),
181+
decimals: CRYPTO_CONSTANTS.USDC_DECIMALS,
176182
});
177183
} catch {
178184
console.log('USDC not available on this chain');
@@ -211,11 +217,11 @@ export class WalletServiceManager {
211217
value: ethers.utils.parseEther(value || '0'),
212218
});
213219
// Network-specific buffer: higher for Polygon due to congestion variability
214-
const bufferMultiplier = chainId === 137 ? 130 : 120;
220+
const bufferMultiplier = chainId === CHAIN_IDS.POLYGON ? CRYPTO_CONSTANTS.POLYGON_GAS_BUFFER_MULTIPLIER : CRYPTO_CONSTANTS.DEFAULT_GAS_BUFFER_MULTIPLIER;
215221
gasLimit = estimated.mul(bufferMultiplier).div(100);
216222
} catch (err) {
217223
console.warn('Gas estimation failed, using safe fallback:', err);
218-
gasLimit = ethers.BigNumber.from(100000);
224+
gasLimit = ethers.BigNumber.from(CRYPTO_CONSTANTS.FALLBACK_GAS_LIMIT);
219225
}
220226
}
221227

@@ -393,7 +399,7 @@ export class WalletServiceManager {
393399
const amountBn = ethers.utils.parseUnits(amount, decimals);
394400

395401
// Sablier V2 LockupLinear is consistently deployed at this address across major EVM networks
396-
const SABLIER_V2_LOCKUP_LINEAR = '0xAFb979d9afAd1aD27C5eFf4E27226E3AB9e5dCC9';
402+
const SABLIER_V2_LOCKUP_LINEAR = ADDRESS_CONSTANTS.SABLIER_V2_LOCKUP_LINEAR;
397403

398404
// 2. Approve Token Spending
399405
const txApprove = await erc20.approve(SABLIER_V2_LOCKUP_LINEAR, amountBn);
@@ -421,7 +427,7 @@ export class WalletServiceManager {
421427
cliff: 0,
422428
total: totalDuration,
423429
},
424-
broker: ethers.constants.AddressZero,
430+
broker: ADDRESS_CONSTANTS.ZERO_ADDRESS,
425431
};
426432

427433
const txCreate = await sablierContract.createWithDurations(params);
@@ -447,18 +453,18 @@ export class WalletServiceManager {
447453

448454
private getNativeSymbol(chainId: number): string {
449455
const symbols: Record<number, string> = {
450-
1: 'ETH',
451-
137: 'MATIC',
452-
42161: 'ETH',
456+
[CHAIN_IDS.ETHEREUM]: 'ETH',
457+
[CHAIN_IDS.POLYGON]: 'MATIC',
458+
[CHAIN_IDS.ARBITRUM]: 'ETH',
453459
};
454460
return symbols[chainId] || 'ETH';
455461
}
456462

457463
private getNativeName(chainId: number): string {
458464
const names: Record<number, string> = {
459-
1: 'Ethereum',
460-
137: 'Polygon',
461-
42161: 'Arbitrum',
465+
[CHAIN_IDS.ETHEREUM]: 'Ethereum',
466+
[CHAIN_IDS.POLYGON]: 'Polygon',
467+
[CHAIN_IDS.ARBITRUM]: 'Arbitrum',
462468
};
463469
return names[chainId] || 'Ethereum';
464470
}

src/store/subscriptionStore.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '../types/subscription';
1111
import { dummySubscriptions } from '../utils/dummyData'; // eslint-disable-line
1212
import { advanceBillingDate } from '../utils/billingDate';
13+
import { BILLING_CONVERSIONS, CACHE_CONSTANTS } from '../utils/constants/values';
1314
import {
1415
syncRenewalReminders,
1516
presentChargeSuccessNotification,
@@ -18,7 +19,17 @@ import {
1819

1920
const STORAGE_KEY = 'subtrackr-subscriptions';
2021
const STORE_VERSION = 1;
21-
const WRITE_DEBOUNCE_MS = 400;
22+
const WRITE_DEBOUNCE_MS = CACHE_CONSTANTS.WRITE_DEBOUNCE_MS;
23+
24+
/**
25+
* Generate a unique ID for subscriptions
26+
* Uses timestamp + random component to prevent collisions
27+
*/
28+
const generateUniqueId = (): string => {
29+
const timestamp = Date.now().toString(36);
30+
const randomComponent = Math.random().toString(36).substring(2, 8);
31+
return `${timestamp}-${randomComponent}`;
32+
};
2233

2334
type PersistedSubscriptionSlice = Pick<SubscriptionState, 'subscriptions'>;
2435

@@ -34,7 +45,7 @@ const toValidDate = (value: unknown, fallback = new Date()): Date => {
3445
const normalizeSubscription = (raw: Partial<Subscription>): Subscription => {
3546
const now = new Date();
3647
return {
37-
id: raw.id ?? Date.now().toString(),
48+
id: raw.id ?? generateUniqueId(),
3849
name: raw.name ?? 'Untitled',
3950
description: raw.description,
4051
category: raw.category ?? SubscriptionCategory.OTHER,
@@ -158,7 +169,7 @@ export const useSubscriptionStore = create<SubscriptionState>()(
158169
set({ isLoading: true, error: null });
159170
try {
160171
const newSubscription: Subscription = {
161-
id: Date.now().toString(),
172+
id: generateUniqueId(),
162173
...data,
163174
isActive: true,
164175
notificationsEnabled: data.notificationsEnabled !== false,
@@ -300,15 +311,15 @@ export const useSubscriptionStore = create<SubscriptionState>()(
300311
const totalMonthlySpend = activeSubs.reduce((total, sub) => {
301312
if (sub.billingCycle === 'monthly') return total + sub.price;
302313
if (sub.billingCycle === 'yearly') return total + sub.price / 12;
303-
if (sub.billingCycle === 'weekly') return total + sub.price * 4;
314+
if (sub.billingCycle === 'weekly') return total + sub.price * BILLING_CONVERSIONS.WEEKS_PER_MONTH;
304315
return total + sub.price;
305316
}, 0);
306317

307318
const totalYearlySpend = activeSubs.reduce((total, sub) => {
308319
if (sub.billingCycle === 'yearly') return total + sub.price;
309-
if (sub.billingCycle === 'monthly') return total + sub.price * 12;
310-
if (sub.billingCycle === 'weekly') return total + sub.price * 52;
311-
return total + sub.price * 12;
320+
if (sub.billingCycle === 'monthly') return total + sub.price * BILLING_CONVERSIONS.MONTHS_PER_YEAR;
321+
if (sub.billingCycle === 'weekly') return total + sub.price * BILLING_CONVERSIONS.WEEKS_PER_YEAR;
322+
return total + sub.price * BILLING_CONVERSIONS.MONTHS_PER_YEAR;
312323
}, 0);
313324

314325
const categoryBreakdown = activeSubs.reduce(

src/utils/constants/values.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Centralized constants for SubTrackr application
3+
* Replaces magic numbers throughout the codebase for better maintainability
4+
*/
5+
6+
// Time constants
7+
export const TIME_CONSTANTS = {
8+
/** Seconds in a minute */
9+
SECONDS_PER_MINUTE: 60,
10+
/** Minutes in an hour */
11+
MINUTES_PER_HOUR: 60,
12+
/** Hours in a day */
13+
HOURS_PER_DAY: 24,
14+
/** Days in a week */
15+
DAYS_PER_WEEK: 7,
16+
/** Average days in a month (30.44) */
17+
DAYS_PER_MONTH: 30.44,
18+
/** Days in a year (365.25 accounting for leap years) */
19+
DAYS_PER_YEAR: 365.25,
20+
/** Seconds in an hour */
21+
SECONDS_PER_HOUR: 60 * 60,
22+
/** Seconds in a day */
23+
SECONDS_PER_DAY: 24 * 60 * 60,
24+
/** Seconds in a week */
25+
SECONDS_PER_WEEK: 7 * 24 * 60 * 60,
26+
/** Approximate seconds in a month (30 days) */
27+
SECONDS_PER_MONTH: 30 * 24 * 60 * 60,
28+
/** Seconds in a year */
29+
SECONDS_PER_YEAR: 365 * 24 * 60 * 60,
30+
/** Milliseconds in a second */
31+
MS_PER_SECOND: 1000,
32+
/** Milliseconds in a minute */
33+
MS_PER_MINUTE: 60 * 1000,
34+
/** Milliseconds in an hour */
35+
MS_PER_HOUR: 60 * 60 * 1000,
36+
/** Milliseconds in a day */
37+
MS_PER_DAY: 24 * 60 * 60 * 1000,
38+
/** Milliseconds in a week */
39+
MS_PER_WEEK: 7 * 24 * 60 * 60 * 1000,
40+
} as const;
41+
42+
// Billing cycle conversion factors
43+
export const BILLING_CONVERSIONS = {
44+
/** Average weeks per month for accurate conversion */
45+
WEEKS_PER_MONTH: 52 / 12, // 4.333...
46+
/** Weeks per year */
47+
WEEKS_PER_YEAR: 52,
48+
/** Months per year */
49+
MONTHS_PER_YEAR: 12,
50+
} as const;
51+
52+
// Cryptocurrency and blockchain constants
53+
export const CRYPTO_CONSTANTS = {
54+
/** Standard ETH decimals */
55+
ETH_DECIMALS: 18,
56+
/** Standard USDC decimals */
57+
USDC_DECIMALS: 6,
58+
/** Wei to ETH conversion factor */
59+
WEI_TO_ETHER: 1e18,
60+
/** Gas limit fallback for estimation failures */
61+
FALLBACK_GAS_LIMIT: 100000,
62+
/** Gas buffer multiplier for Polygon (130%) */
63+
POLYGON_GAS_BUFFER_MULTIPLIER: 130,
64+
/** Gas buffer multiplier for other networks (120%) */
65+
DEFAULT_GAS_BUFFER_MULTIPLIER: 120,
66+
} as const;
67+
68+
// Cache and performance constants
69+
export const CACHE_CONSTANTS = {
70+
/** Cache TTL in milliseconds (1 minute) */
71+
CACHE_TTL_MS: 60_000,
72+
/** Write debounce delay in milliseconds */
73+
WRITE_DEBOUNCE_MS: 400,
74+
} as const;
75+
76+
// Default values and limits
77+
export const DEFAULT_VALUES = {
78+
/** Default currency */
79+
DEFAULT_CURRENCY: 'USD',
80+
/** Default billing cycle */
81+
DEFAULT_BILLING_CYCLE: 'monthly',
82+
/** Maximum length for text truncation */
83+
MAX_TEXT_LENGTH: 50,
84+
/** Default flow rate buffer percentage */
85+
FLOW_RATE_BUFFER_PERCENTAGE: 20,
86+
} as const;
87+
88+
// Address constants
89+
export const ADDRESS_CONSTANTS = {
90+
/** Zero address for Ethereum */
91+
ZERO_ADDRESS: '0x0000000000000000000000000000000000000000',
92+
/** Sablier V2 LockupLinear contract address */
93+
SABLIER_V2_LOCKUP_LINEAR: '0xAFb979d9afAd1aD27C5eFf4E27226E3AB9e5dCC9',
94+
} as const;
95+
96+
// Chain ID constants
97+
export const CHAIN_IDS = {
98+
/** Ethereum Mainnet */
99+
ETHEREUM: 1,
100+
/** Polygon Mainnet */
101+
POLYGON: 137,
102+
/** Arbitrum Mainnet */
103+
ARBITRUM: 42161,
104+
} as const;
105+
106+
// Formatting constants
107+
export const FORMATTING_CONSTANTS = {
108+
/** Default address start characters */
109+
ADDRESS_START_CHARS: 6,
110+
/** Default address end characters */
111+
ADDRESS_END_CHARS: 4,
112+
/** Maximum fraction digits for compact currency */
113+
COMPACT_MAX_FRACTION_DIGITS: 1,
114+
/** Maximum fraction digits for regular currency */
115+
REGULAR_MAX_FRACTION_DIGITS: 2,
116+
/** Default crypto decimals for formatting */
117+
DEFAULT_CRYPTO_DECIMALS: 18,
118+
} as const;

0 commit comments

Comments
 (0)