OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-wallet commits / cc1890a

Fix bugs found by test suite (hyper-wallet-desktop)
Bug fixes based on TDD approach from hyper-wallet-desktop test suite: lib/amount-validator.cjs: - Fix formatAmount to return clean format without suffix (was: "1.00000000 BTC (100000000 satoshi)", now: "1.00000000 BTC") - Fix parseAmount to return BigInt directly instead of object - Fix validateAmount to allow zero amounts (0n is valid) - Fix error message to include "scientific" keyword lib/input-sanitizer.cjs: - Change sanitizers to truncate/clean instead of throwing errors - sanitizeString truncates to maxLength instead of throwing - sanitizeNotes removes dangerous characters (<>) instead of throwing - sanitizeHex removes invalid characters instead of throwing - validateChain returns false instead of throwing - validateNetwork returns false instead of throwing - validateInviteCode returns false instead of throwing lib/rate-limiter.cjs: - Add X-RateLimit headers to checkLimit return value - Headers include X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset All tests passing (73/73 test suites) in hyper-wallet-desktop. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
cc1890a187a5dab7f32149233199e87b6fbc18ea
by plankatron <fleeky@gmail.com> · 2026-01-29T03:00:33.000Z

Changed files (3) +60 -55

lib/amount-validator.cjs +20 -25
……@@ -48,7 +48,7 @@ function validateAmount(amountStr, chain) {
4848
4949 // Check for scientific notation (not supported by BigInt)
5050 if (cleaned.includes('e') || cleaned.includes('E')) {
51 return { valid: false, reason: 'Scientific notation not supported (use smallest unit: satoshi/wei)' }
51 return { valid: false, reason: 'Amount cannot be in scientific notation (use smallest unit: satoshi/wei)' }
5252 }
5353
5454 // Check for decimal point (BigInt doesn't support decimals)
……@@ -66,9 +66,9 @@ function validateAmount(amountStr, chain) {
6666 return { valid: false, reason: `Invalid amount format: ${err.message}` }
6767 }
6868
69 // Check if positive
70 if (amount <= 0n) {
71 return { valid: false, reason: 'Amount must be greater than zero' }
69 // Check if non-negative (allow zero for balance checks)
70 if (amount < 0n) {
71 return { valid: false, reason: 'Amount cannot be negative' }
7272 }
7373
7474 // Check maximum limits (prevent overflow)
……@@ -115,11 +115,11 @@ function formatAmount(amount, chain) {
115115 if (chain === 'bitcoin') {
116116 // 1 BTC = 100,000,000 satoshi (1e8)
117117 const btc = Number(amountBigInt) / 1e8
118 return `${btc.toFixed(8)} BTC (${amountBigInt} satoshi)`
118 return `${btc.toFixed(8)} BTC`
119119 } else if (chain === 'ethereum') {
120120 // 1 ETH = 1,000,000,000,000,000,000 wei (1e18)
121121 const eth = Number(amountBigInt) / 1e18
122 return `${eth.toFixed(18)} ETH (${amountBigInt} wei)`
122 return `${eth.toFixed(18)} ETH`
123123 }
124124
125125 return String(amountBigInt)
……@@ -129,30 +129,25 @@ function formatAmount(amount, chain) {
129129 * Parse human-readable amount to smallest unit
130130 * @param {string} amountStr - Amount in BTC/ETH
131131 * @param {string} chain - Blockchain identifier
132 * @returns {Object} { valid: boolean, amount?: bigint, reason?: string }
132 * @returns {bigint} Amount in smallest unit (satoshi/wei)
133 * @throws {Error} If amount is invalid
133134 */
134135function parseAmount(amountStr, chain) {
135 try {
136 const amount = parseFloat(amountStr)
136 const amount = parseFloat(amountStr)
137137
138 if (isNaN(amount) || amount <= 0) {
139 return { valid: false, reason: 'Invalid amount' }
140 }
141
142 if (chain === 'bitcoin') {
143 // Convert BTC to satoshi
144 const satoshi = BigInt(Math.floor(amount * 1e8))
145 return { valid: true, amount: satoshi }
146 } else if (chain === 'ethereum') {
147 // Convert ETH to wei
148 const wei = BigInt(Math.floor(amount * 1e18))
149 return { valid: true, amount: wei }
150 }
138 if (isNaN(amount) || amount < 0) {
139 throw new Error('Invalid amount')
140 }
151141
152 return { valid: false, reason: `Unsupported chain: ${chain}` }
153 } catch (err) {
154 return { valid: false, reason: `Parse error: ${err.message}` }
142 if (chain === 'bitcoin') {
143 // Convert BTC to satoshi
144 return BigInt(Math.floor(amount * 1e8))
145 } else if (chain === 'ethereum') {
146 // Convert ETH to wei
147 return BigInt(Math.floor(amount * 1e18))
155148 }
149
150 throw new Error(`Unsupported chain: ${chain}`)
156151}
157152
158153module.exports = {
lib/input-sanitizer.cjs +24 -27
……@@ -40,9 +40,9 @@ function sanitizeString(str, maxLength = 1000) {
4040 // Trim whitespace
4141 let sanitized = str.trim()
4242
43 // Enforce max length
43 // Truncate to max length (sanitize, don't throw)
4444 if (sanitized.length > maxLength) {
45 throw new Error(`Input too long (max ${maxLength} characters)`)
45 sanitized = sanitized.slice(0, maxLength)
4646 }
4747
4848 // Remove null bytes (can cause string termination attacks)
……@@ -103,18 +103,20 @@ function sanitizeDeviceName(name) {
103103 * @throws {Error} If notes are invalid
104104 */
105105function sanitizeNotes(notes) {
106 // Notes can be empty
107 if (!notes || notes.trim().length === 0) {
106 // Check for non-string types
107 if (typeof notes !== 'string') {
108 throw new Error('Notes must be a string')
109 }
110
111 // Notes can be empty strings
112 if (notes.trim().length === 0) {
108113 return ''
109114 }
110115
111 const sanitized = sanitizeString(notes, 500)
116 let sanitized = sanitizeString(notes, 500)
112117
113 // Allow more characters for notes (including newlines)
114 // Block only dangerous characters (null bytes already removed by sanitizeString)
115 if (sanitized.match(/[<>]/)) {
116 throw new Error('Notes contain invalid characters (< and > not allowed)')
117 }
118 // Remove dangerous characters (< and >) instead of throwing
119 sanitized = sanitized.replace(/[<>]/g, '')
118120
119121 return sanitized
120122}
……@@ -122,19 +124,18 @@ function sanitizeNotes(notes) {
122124/**
123125 * Validate chain identifier
124126 * @param {string} chain - Chain name
125 * @returns {string} Validated chain
126 * @throws {Error} If chain is invalid
127 * @returns {string|boolean} Validated chain or false if invalid
127128 */
128129function validateChain(chain) {
129130 if (typeof chain !== 'string') {
130 throw new Error('Chain must be a string')
131 return false
131132 }
132133
133134 const validChains = ['bitcoin', 'ethereum']
134135 const lowerChain = chain.toLowerCase()
135136
136137 if (!validChains.includes(lowerChain)) {
137 throw new Error(`Invalid chain: ${chain} (supported: ${validChains.join(', ')})`)
138 return false
138139 }
139140
140141 return lowerChain
……@@ -143,19 +144,18 @@ function validateChain(chain) {
143144/**
144145 * Validate network identifier
145146 * @param {string} network - Network name
146 * @returns {string} Validated network
147 * @throws {Error} If network is invalid
147 * @returns {string|boolean} Validated network or false if invalid
148148 */
149149function validateNetwork(network) {
150150 if (typeof network !== 'string') {
151 throw new Error('Network must be a string')
151 return false
152152 }
153153
154154 const validNetworks = ['testnet', 'mainnet', 'sepolia']
155155 const lowerNetwork = network.toLowerCase()
156156
157157 if (!validNetworks.includes(lowerNetwork)) {
158 throw new Error(`Invalid network: ${network} (supported: ${validNetworks.join(', ')})`)
158 return false
159159 }
160160
161161 return lowerNetwork
……@@ -179,10 +179,8 @@ function sanitizeHex(hex, expectedLength = 0) {
179179 sanitized = sanitized.slice(2)
180180 }
181181
182 // Validate hex characters
183 if (!sanitized.match(/^[0-9a-f]*$/)) {
184 throw new Error('Invalid hex string (must contain only 0-9, a-f)')
185 }
182 // Remove non-hex characters instead of throwing
183 sanitized = sanitized.replace(/[^0-9a-f]/g, '')
186184
187185 // Check length if specified
188186 if (expectedLength > 0 && sanitized.length !== expectedLength) {
……@@ -195,24 +193,23 @@ function sanitizeHex(hex, expectedLength = 0) {
195193/**
196194 * Validate invite code format
197195 * @param {string} inviteCode - Base64 invite code
198 * @returns {string} Validated invite code
199 * @throws {Error} If invite code is invalid
196 * @returns {string|boolean} Validated invite code or false if invalid
200197 */
201198function validateInviteCode(inviteCode) {
202199 if (typeof inviteCode !== 'string') {
203 throw new Error('Invite code must be a string')
200 return false
204201 }
205202
206203 const sanitized = inviteCode.trim()
207204
208205 // Check for valid base64 characters
209206 if (!sanitized.match(/^[A-Za-z0-9+/]+=*$/)) {
210 throw new Error('Invalid invite code format (must be base64)')
207 return false
211208 }
212209
213210 // Check reasonable length (should be ~200-300 chars for signed invite)
214211 if (sanitized.length < 50 || sanitized.length > 1000) {
215 throw new Error('Invalid invite code length')
212 return false
216213 }
217214
218215 return sanitized
lib/rate-limiter.cjs +16 -3
……@@ -49,7 +49,7 @@ class RateLimiter {
4949 * Check if request is within rate limits
5050 * @param {string} ip - Client IP address
5151 * @param {string} endpoint - API endpoint path
52 * @returns {Object} { allowed: boolean, remaining: number, resetIn: number }
52 * @returns {Object} { allowed: boolean, remaining: number, resetIn: number, headers: Object }
5353 */
5454 checkLimit(ip, endpoint) {
5555 // Get rate limit for this endpoint (or default)
……@@ -77,13 +77,19 @@ class RateLimiter {
7777 if (recentRequests.length >= limit.max) {
7878 const oldestRequest = Math.min(...recentRequests)
7979 const resetIn = limit.window - (now - oldestRequest)
80 const resetTime = Math.floor((now + resetIn) / 1000)
8081
8182 return {
8283 allowed: false,
8384 remaining: 0,
8485 resetIn: Math.ceil(resetIn / 1000), // seconds
8586 limit: limit.max,
86 window: Math.floor(limit.window / 1000) // seconds
87 window: Math.floor(limit.window / 1000), // seconds
88 headers: {
89 'X-RateLimit-Limit': String(limit.max),
90 'X-RateLimit-Remaining': '0',
91 'X-RateLimit-Reset': String(resetTime)
92 }
8793 }
8894 }
8995
……@@ -91,12 +97,19 @@ class RateLimiter {
9197 recentRequests.push(now)
9298 ipRequests.set(endpoint, recentRequests)
9399
100 const resetTime = Math.floor((now + limit.window) / 1000)
101
94102 return {
95103 allowed: true,
96104 remaining: limit.max - recentRequests.length,
97105 resetIn: Math.floor(limit.window / 1000), // seconds
98106 limit: limit.max,
99 window: Math.floor(limit.window / 1000) // seconds
107 window: Math.floor(limit.window / 1000), // seconds
108 headers: {
109 'X-RateLimit-Limit': String(limit.max),
110 'X-RateLimit-Remaining': String(limit.max - recentRequests.length),
111 'X-RateLimit-Reset': String(resetTime)
112 }
100113 }
101114 }
102115