minimal-xec-wallet 1.0.1
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.
- package/LICENSE +21 -0
- package/README.md +241 -0
- package/dist/minimal-xec-wallet.js +66268 -0
- package/dist/minimal-xec-wallet.min.js +55 -0
- package/examples/README.md +380 -0
- package/examples/advanced/browser-compatibility-test.js +263 -0
- package/examples/advanced/get-xec-price.js +149 -0
- package/examples/advanced/optimize-utxos.js +255 -0
- package/examples/advanced/send-op-return.js +216 -0
- package/examples/browser-test.html +350 -0
- package/examples/key-management/derive-addresses.js +191 -0
- package/examples/key-management/export-to-wif.js +114 -0
- package/examples/key-management/validate-address.js +214 -0
- package/examples/optimization/simple-consolidation-test.js +79 -0
- package/examples/optimization/test-utxo-consolidation.js +179 -0
- package/examples/test-examples.js +1204 -0
- package/examples/tokens/burn-tokens.js +293 -0
- package/examples/tokens/get-token-balance.js +169 -0
- package/examples/tokens/get-token-info.js +269 -0
- package/examples/tokens/list-all-tokens.js +162 -0
- package/examples/tokens/send-any-token.js +260 -0
- package/examples/tokens/test-main-wallet-integration.js +193 -0
- package/examples/transactions/send-all-xec.js +205 -0
- package/examples/transactions/send-to-multiple.js +217 -0
- package/examples/transactions/send-xec.js +191 -0
- package/examples/utils/show-qr.js +119 -0
- package/examples/utils/wallet-helper.js +176 -0
- package/examples/validation/comprehensive-infrastructure-test.js +210 -0
- package/examples/wallet-creation/create-new-wallet.js +67 -0
- package/examples/wallet-creation/import-from-wif.js +135 -0
- package/examples/wallet-creation/restore-from-mnemonic.js +100 -0
- package/examples/wallet-info/get-balance.js +99 -0
- package/examples/wallet-info/get-transactions.js +157 -0
- package/examples/wallet-info/get-utxos.js +145 -0
- package/examples/wallet.json +11 -0
- package/lib/adapters/robust-chronik-router.js +507 -0
- package/lib/adapters/router.js +651 -0
- package/lib/alp-token-handler.js +581 -0
- package/lib/browser-wasm-loader.js +271 -0
- package/lib/consolidate-utxos.js +338 -0
- package/lib/hybrid-token-manager.js +322 -0
- package/lib/key-derivation.js +466 -0
- package/lib/op-return.js +314 -0
- package/lib/security.js +270 -0
- package/lib/send-xec.js +396 -0
- package/lib/slp-token-handler.js +572 -0
- package/lib/token-protocol-detector.js +307 -0
- package/lib/utxos.js +303 -0
- package/package.json +125 -0
package/lib/op-return.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/*
|
|
2
|
+
This library handles OP_RETURN operations for XEC transactions.
|
|
3
|
+
Uses same patterns as send-xec.js for consistency.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { TxBuilder, P2PKHSignatory, fromHex, toHex, Ecc, Script, ALL_BIP143 } = require('ecash-lib')
|
|
7
|
+
const { decodeCashAddress } = require('ecashaddrjs')
|
|
8
|
+
const KeyDerivation = require('./key-derivation')
|
|
9
|
+
const SecurityValidator = require('./security')
|
|
10
|
+
|
|
11
|
+
class OpReturn {
|
|
12
|
+
constructor (localConfig = {}) {
|
|
13
|
+
this.chronik = localConfig.chronik
|
|
14
|
+
this.ar = localConfig.ar
|
|
15
|
+
|
|
16
|
+
if (!this.chronik) {
|
|
17
|
+
throw new Error('Chronik client required for OP_RETURN transactions')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!this.ar) {
|
|
21
|
+
throw new Error('AdapterRouter required for OP_RETURN transactions')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Initialize components (same as send-xec.js)
|
|
25
|
+
this.keyDerivation = new KeyDerivation()
|
|
26
|
+
this.security = new SecurityValidator(localConfig.security)
|
|
27
|
+
|
|
28
|
+
// Initialize ECC for ecash-lib
|
|
29
|
+
try {
|
|
30
|
+
this.ecc = new Ecc()
|
|
31
|
+
} catch (err) {
|
|
32
|
+
throw new Error(`Ecc initialization failed: ${err.message}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Configuration - XEC uses 546 satoshis (5.46 XEC) as standard dust limit
|
|
36
|
+
this.dustLimit = localConfig.dustLimit || 546
|
|
37
|
+
this.maxOpReturnSize = localConfig.maxOpReturnSize || 223 // Max OP_RETURN size in bytes
|
|
38
|
+
this.defaultSatsPerByte = localConfig.defaultSatsPerByte || 1.2
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async sendOpReturn (walletInfo, xecUtxos, msg, prefix = '6d02', xecOutput = [], satsPerByte = this.defaultSatsPerByte) {
|
|
42
|
+
try {
|
|
43
|
+
// Create OP_RETURN transaction
|
|
44
|
+
const txHex = await this.createOpReturnTx(walletInfo, xecUtxos, msg, prefix, xecOutput, satsPerByte)
|
|
45
|
+
|
|
46
|
+
// Broadcast transaction
|
|
47
|
+
const txid = await this.ar.sendTx(txHex)
|
|
48
|
+
|
|
49
|
+
return txid
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new Error(`OP_RETURN send failed: ${err.message}`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async createOpReturnTx (walletInfo, xecUtxos, msg, prefix = '6d02', xecOutput = [], satsPerByte = this.defaultSatsPerByte) {
|
|
56
|
+
try {
|
|
57
|
+
// Validate inputs
|
|
58
|
+
if (!walletInfo || !walletInfo.xecAddress) {
|
|
59
|
+
throw new Error('Valid wallet info required')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!xecUtxos || xecUtxos.length === 0) {
|
|
63
|
+
throw new Error('UTXOs required for OP_RETURN transaction')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Build OP_RETURN script
|
|
67
|
+
const opReturnScript = this.buildOpReturnScript(msg, prefix)
|
|
68
|
+
|
|
69
|
+
// Calculate total output amount (excluding OP_RETURN which is always 0)
|
|
70
|
+
let totalOutputAmount = 0
|
|
71
|
+
for (const output of xecOutput) {
|
|
72
|
+
if (!output.address || typeof output.amountSat !== 'number') {
|
|
73
|
+
throw new Error('Invalid XEC output format')
|
|
74
|
+
}
|
|
75
|
+
totalOutputAmount += output.amountSat
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Select UTXOs using the same logic as send-xec.js
|
|
79
|
+
const coinSelection = this._selectUtxosForOpReturn(
|
|
80
|
+
totalOutputAmount,
|
|
81
|
+
xecUtxos,
|
|
82
|
+
satsPerByte,
|
|
83
|
+
xecOutput.length + 1 // +1 for OP_RETURN output
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
// Get private key (prefer mnemonic - same as send-xec.js)
|
|
87
|
+
let privateKeyHex
|
|
88
|
+
if (walletInfo.mnemonic) {
|
|
89
|
+
const keyData = this.keyDerivation.deriveFromMnemonic(walletInfo.mnemonic, walletInfo.hdPath)
|
|
90
|
+
privateKeyHex = keyData.privateKey
|
|
91
|
+
} else {
|
|
92
|
+
privateKeyHex = walletInfo.privateKey
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sk = fromHex(privateKeyHex)
|
|
96
|
+
const pk = this.ecc.derivePubkey(sk)
|
|
97
|
+
|
|
98
|
+
// Build outputs array (same pattern as send-xec.js)
|
|
99
|
+
const txOutputs = []
|
|
100
|
+
|
|
101
|
+
// Add OP_RETURN output first (0 value)
|
|
102
|
+
txOutputs.push({
|
|
103
|
+
sats: BigInt(0),
|
|
104
|
+
script: new Script(opReturnScript)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Add XEC outputs
|
|
108
|
+
for (const output of xecOutput) {
|
|
109
|
+
const decoded = decodeCashAddress(output.address)
|
|
110
|
+
txOutputs.push({
|
|
111
|
+
sats: BigInt(output.amountSat),
|
|
112
|
+
script: Script.p2pkh(fromHex(decoded.hash))
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Add change address for automatic calculation (same as send-xec.js)
|
|
117
|
+
const walletDecoded = decodeCashAddress(walletInfo.xecAddress)
|
|
118
|
+
txOutputs.push(Script.p2pkh(fromHex(walletDecoded.hash)))
|
|
119
|
+
|
|
120
|
+
// Build inputs (same pattern as send-xec.js)
|
|
121
|
+
const inputs = coinSelection.necessaryUtxos.map(utxo => ({
|
|
122
|
+
input: {
|
|
123
|
+
prevOut: {
|
|
124
|
+
txid: utxo.outpoint.txid,
|
|
125
|
+
outIdx: utxo.outpoint.outIdx
|
|
126
|
+
},
|
|
127
|
+
signData: {
|
|
128
|
+
sats: BigInt(this._getUtxoValue(utxo)),
|
|
129
|
+
outputScript: Script.p2pkh(fromHex(walletDecoded.hash))
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
signatory: P2PKHSignatory(sk, pk, ALL_BIP143)
|
|
133
|
+
}))
|
|
134
|
+
|
|
135
|
+
// Build and sign transaction (same as send-xec.js)
|
|
136
|
+
const txBuilder = new TxBuilder({ inputs, outputs: txOutputs })
|
|
137
|
+
const tx = txBuilder.sign({
|
|
138
|
+
feePerKb: BigInt(Math.round(satsPerByte * 1000)),
|
|
139
|
+
dustSats: BigInt(this.dustLimit)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
return toHex(tx.ser())
|
|
143
|
+
} catch (err) {
|
|
144
|
+
throw new Error(`OP_RETURN transaction creation failed: ${err.message}`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
buildOpReturnScript (msg, prefix = '6d02') {
|
|
149
|
+
try {
|
|
150
|
+
// Convert message to buffer
|
|
151
|
+
const msgBuffer = Buffer.isBuffer(msg) ? msg : Buffer.from(msg, 'utf8')
|
|
152
|
+
|
|
153
|
+
// Convert prefix from hex if it's a string
|
|
154
|
+
const prefixBuffer = typeof prefix === 'string' ? Buffer.from(prefix, 'hex') : prefix
|
|
155
|
+
|
|
156
|
+
// Calculate total size
|
|
157
|
+
const totalSize = prefixBuffer.length + msgBuffer.length
|
|
158
|
+
|
|
159
|
+
if (totalSize > this.maxOpReturnSize) {
|
|
160
|
+
throw new Error(`OP_RETURN data too large: ${totalSize} bytes (max: ${this.maxOpReturnSize})`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Build OP_RETURN script
|
|
164
|
+
// Format: OP_RETURN <prefix> <message>
|
|
165
|
+
const script = Buffer.alloc(2 + prefixBuffer.length + msgBuffer.length)
|
|
166
|
+
let offset = 0
|
|
167
|
+
|
|
168
|
+
// OP_RETURN opcode
|
|
169
|
+
script[offset++] = 0x6a
|
|
170
|
+
|
|
171
|
+
// Push data opcode based on total size
|
|
172
|
+
if (totalSize <= 75) {
|
|
173
|
+
script[offset++] = totalSize
|
|
174
|
+
} else if (totalSize <= 255) {
|
|
175
|
+
script[offset++] = 0x4c // OP_PUSHDATA1
|
|
176
|
+
script[offset++] = totalSize
|
|
177
|
+
} else {
|
|
178
|
+
throw new Error('OP_RETURN data too large for standard push operations')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Add prefix
|
|
182
|
+
prefixBuffer.copy(script, offset)
|
|
183
|
+
offset += prefixBuffer.length
|
|
184
|
+
|
|
185
|
+
// Add message
|
|
186
|
+
msgBuffer.copy(script, offset)
|
|
187
|
+
|
|
188
|
+
return script
|
|
189
|
+
} catch (err) {
|
|
190
|
+
throw new Error(`OP_RETURN script creation failed: ${err.message}`)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Helper methods
|
|
195
|
+
|
|
196
|
+
_selectUtxosForOpReturn (totalOutputAmount, availableUtxos, satsPerByte, numOutputs) {
|
|
197
|
+
try {
|
|
198
|
+
// Filter secure UTXOs (same as send-xec.js)
|
|
199
|
+
let secureUtxos = this.security.filterSecureUtxos(availableUtxos, { excludeDustAttack: false })
|
|
200
|
+
|
|
201
|
+
// If no confirmed UTXOs available, allow unconfirmed ones
|
|
202
|
+
if (secureUtxos.length === 0) {
|
|
203
|
+
console.warn('No confirmed UTXOs available, including unconfirmed UTXOs')
|
|
204
|
+
secureUtxos = this.security.filterSecureUtxos(availableUtxos, { includeUnconfirmed: true, excludeDustAttack: false })
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (secureUtxos.length === 0) {
|
|
208
|
+
throw new Error('No spendable UTXOs available')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Sort UTXOs by size (largest first) - using proper value extraction
|
|
212
|
+
const sortedUtxos = secureUtxos.sort((a, b) => {
|
|
213
|
+
return this._getUtxoValue(b) - this._getUtxoValue(a)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const selectedUtxos = []
|
|
217
|
+
let totalInputAmount = 0
|
|
218
|
+
let estimatedFee = 0
|
|
219
|
+
|
|
220
|
+
for (const utxo of sortedUtxos) {
|
|
221
|
+
const utxoValue = this._getUtxoValue(utxo)
|
|
222
|
+
selectedUtxos.push(utxo)
|
|
223
|
+
totalInputAmount += utxoValue
|
|
224
|
+
|
|
225
|
+
// Calculate fee including potential change output
|
|
226
|
+
const numInputs = selectedUtxos.length
|
|
227
|
+
const hasChange = (totalInputAmount - totalOutputAmount) > this.dustLimit
|
|
228
|
+
const totalOutputs = numOutputs + (hasChange ? 1 : 0) // +1 for change if needed
|
|
229
|
+
|
|
230
|
+
estimatedFee = this._calculateFee(numInputs, totalOutputs, satsPerByte)
|
|
231
|
+
const totalNeeded = totalOutputAmount + estimatedFee
|
|
232
|
+
|
|
233
|
+
if (totalInputAmount >= totalNeeded) {
|
|
234
|
+
const change = totalInputAmount - totalNeeded
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
necessaryUtxos: selectedUtxos,
|
|
238
|
+
totalAmount: totalInputAmount,
|
|
239
|
+
estimatedFee,
|
|
240
|
+
change: change > this.dustLimit ? change : 0
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
throw new Error(`Insufficient funds for OP_RETURN transaction. Need: ${totalOutputAmount + estimatedFee}, Available: ${totalInputAmount}`)
|
|
246
|
+
} catch (err) {
|
|
247
|
+
throw new Error(`UTXO selection for OP_RETURN failed: ${err.message}`)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_calculateFee (numInputs, numOutputs, satsPerByte) {
|
|
252
|
+
// Estimate transaction size in bytes
|
|
253
|
+
const estimatedSize = (numInputs * 148) + (numOutputs * 34) + 10
|
|
254
|
+
return Math.ceil(estimatedSize * satsPerByte)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_createP2PKHScript (hash160) {
|
|
258
|
+
// Create Pay-to-Public-Key-Hash script
|
|
259
|
+
const script = Buffer.alloc(25)
|
|
260
|
+
script[0] = 0x76 // OP_DUP
|
|
261
|
+
script[1] = 0xa9 // OP_HASH160
|
|
262
|
+
script[2] = 0x14 // Push 20 bytes
|
|
263
|
+
hash160.copy(script, 3)
|
|
264
|
+
script[23] = 0x88 // OP_EQUALVERIFY
|
|
265
|
+
script[24] = 0xac // OP_CHECKSIG
|
|
266
|
+
return script
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
_validateMessage (msg) {
|
|
270
|
+
if (msg === null || msg === undefined) {
|
|
271
|
+
return Buffer.alloc(0) // Empty message
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (Buffer.isBuffer(msg)) {
|
|
275
|
+
return msg
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (typeof msg === 'string') {
|
|
279
|
+
return Buffer.from(msg, 'utf8')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
throw new Error('Message must be a string or Buffer')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
_validatePrefix (prefix) {
|
|
286
|
+
if (!prefix) {
|
|
287
|
+
return Buffer.from('6d02', 'hex') // Default memo.cash prefix
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (Buffer.isBuffer(prefix)) {
|
|
291
|
+
return prefix
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (typeof prefix === 'string') {
|
|
295
|
+
// Assume hex string
|
|
296
|
+
return Buffer.from(prefix, 'hex')
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
throw new Error('Prefix must be a hex string or Buffer')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Same UTXO value extraction as send-xec.js
|
|
303
|
+
_getUtxoValue (utxo) {
|
|
304
|
+
if (utxo.sats !== undefined) {
|
|
305
|
+
return typeof utxo.sats === 'bigint' ? Number(utxo.sats) : parseInt(utxo.sats)
|
|
306
|
+
}
|
|
307
|
+
if (utxo.value !== undefined) {
|
|
308
|
+
return typeof utxo.value === 'bigint' ? Number(utxo.value) : parseInt(utxo.value)
|
|
309
|
+
}
|
|
310
|
+
return 0
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
module.exports = OpReturn
|
package/lib/security.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Security validations for minimal XEC wallet
|
|
3
|
+
|
|
4
|
+
Essential security features without over-engineering:
|
|
5
|
+
- Dust attack protection
|
|
6
|
+
- Basic suspicious pattern detection
|
|
7
|
+
- Input validation
|
|
8
|
+
- UTXO safety checks
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
class SecurityValidator {
|
|
12
|
+
constructor (config = {}) {
|
|
13
|
+
// Simple security thresholds - XEC uses 546 satoshis (5.46 XEC) as standard dust limit
|
|
14
|
+
this.dustThreshold = config.dustThreshold || 546
|
|
15
|
+
this.maxTransactionSize = config.maxTransactionSize || 100000 // 100KB
|
|
16
|
+
this.suspiciousPatternThreshold = config.suspiciousPatternThreshold || 10
|
|
17
|
+
this.maxOutputs = config.maxOutputs || 50
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if UTXO is safe to spend
|
|
22
|
+
* @param {Object} utxo - UTXO to validate
|
|
23
|
+
* @param {Object} options - Validation options
|
|
24
|
+
* @returns {boolean} - True if safe to spend
|
|
25
|
+
*/
|
|
26
|
+
isSecureUtxo (utxo, options = {}) {
|
|
27
|
+
try {
|
|
28
|
+
// Basic structure validation
|
|
29
|
+
if (!this.isValidUtxoStructure(utxo)) {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const satsValue = this._extractSats(utxo)
|
|
34
|
+
|
|
35
|
+
// Dust protection
|
|
36
|
+
if (satsValue < this.dustThreshold) {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check confirmation status
|
|
41
|
+
const { includeUnconfirmed = false } = options
|
|
42
|
+
if (!includeUnconfirmed && utxo.blockHeight === -1) {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return true
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.warn('UTXO security validation failed:', err.message)
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Detect potential dust attacks in UTXO set
|
|
55
|
+
* @param {Array} utxos - Array of UTXOs to analyze
|
|
56
|
+
* @returns {Object} - Analysis result
|
|
57
|
+
*/
|
|
58
|
+
analyzeDustAttack (utxos) {
|
|
59
|
+
const analysis = {
|
|
60
|
+
isDustAttack: false,
|
|
61
|
+
suspiciousUtxos: [],
|
|
62
|
+
duplicateAmounts: new Map(),
|
|
63
|
+
recommendation: 'safe'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// Group by amount
|
|
68
|
+
const amountGroups = new Map()
|
|
69
|
+
|
|
70
|
+
utxos.forEach((utxo, index) => {
|
|
71
|
+
const sats = this._extractSats(utxo)
|
|
72
|
+
|
|
73
|
+
// Only consider small amounts for dust attack analysis
|
|
74
|
+
if (sats < 5000) { // Less than 50 XEC
|
|
75
|
+
if (!amountGroups.has(sats)) {
|
|
76
|
+
amountGroups.set(sats, [])
|
|
77
|
+
}
|
|
78
|
+
amountGroups.get(sats).push({ utxo, index })
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Check for suspicious patterns
|
|
83
|
+
for (const [amount, group] of amountGroups) {
|
|
84
|
+
if (group.length >= this.suspiciousPatternThreshold) {
|
|
85
|
+
analysis.isDustAttack = true
|
|
86
|
+
analysis.suspiciousUtxos.push(...group.map(g => g.index))
|
|
87
|
+
analysis.duplicateAmounts.set(amount, group.length)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Set recommendation
|
|
92
|
+
if (analysis.isDustAttack) {
|
|
93
|
+
analysis.recommendation = 'exclude_suspicious'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return analysis
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.warn('Dust attack analysis failed:', err.message)
|
|
99
|
+
return analysis
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Validate transaction outputs for security
|
|
105
|
+
* @param {Array} outputs - Transaction outputs
|
|
106
|
+
* @returns {Object} - Validation result
|
|
107
|
+
*/
|
|
108
|
+
validateOutputs (outputs) {
|
|
109
|
+
const validation = {
|
|
110
|
+
isValid: true,
|
|
111
|
+
errors: [],
|
|
112
|
+
totalAmount: 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Check output count
|
|
117
|
+
if (outputs.length === 0) {
|
|
118
|
+
validation.isValid = false
|
|
119
|
+
validation.errors.push('No outputs specified')
|
|
120
|
+
return validation
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (outputs.length > this.maxOutputs) {
|
|
124
|
+
validation.isValid = false
|
|
125
|
+
validation.errors.push(`Too many outputs: ${outputs.length} > ${this.maxOutputs}`)
|
|
126
|
+
return validation
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Validate each output
|
|
130
|
+
for (let i = 0; i < outputs.length; i++) {
|
|
131
|
+
const output = outputs[i]
|
|
132
|
+
|
|
133
|
+
// Address validation
|
|
134
|
+
if (!this.isValidAddress(output.address)) {
|
|
135
|
+
validation.isValid = false
|
|
136
|
+
validation.errors.push(`Invalid address at output ${i}: ${output.address}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Amount validation
|
|
140
|
+
const amount = this._validateAmount(output.amountSat || output.amount)
|
|
141
|
+
if (amount === null) {
|
|
142
|
+
validation.isValid = false
|
|
143
|
+
validation.errors.push(`Invalid amount at output ${i}: ${output.amountSat || output.amount}`)
|
|
144
|
+
} else {
|
|
145
|
+
validation.totalAmount += amount
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check for amount overflow
|
|
150
|
+
if (validation.totalAmount > Number.MAX_SAFE_INTEGER) {
|
|
151
|
+
validation.isValid = false
|
|
152
|
+
validation.errors.push('Total amount exceeds safe integer limits')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return validation
|
|
156
|
+
} catch (err) {
|
|
157
|
+
validation.isValid = false
|
|
158
|
+
validation.errors.push(`Output validation failed: ${err.message}`)
|
|
159
|
+
return validation
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if address is valid XEC format
|
|
165
|
+
* @param {string} address - Address to validate
|
|
166
|
+
* @returns {boolean} - True if valid
|
|
167
|
+
*/
|
|
168
|
+
isValidAddress (address) {
|
|
169
|
+
try {
|
|
170
|
+
if (typeof address !== 'string') {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Must start with ecash: prefix
|
|
175
|
+
if (!address.startsWith('ecash:')) {
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Try to decode with ecashaddrjs for proper validation
|
|
180
|
+
const { decodeCashAddress } = require('ecashaddrjs')
|
|
181
|
+
const decoded = decodeCashAddress(address)
|
|
182
|
+
|
|
183
|
+
// Must be P2PKH (type 0) - case insensitive check
|
|
184
|
+
return decoded.type === 'P2PKH' || decoded.type === 'p2pkh'
|
|
185
|
+
} catch (err) {
|
|
186
|
+
// Invalid address format
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Validate UTXO structure
|
|
193
|
+
* @param {Object} utxo - UTXO to validate
|
|
194
|
+
* @returns {boolean} - True if valid structure
|
|
195
|
+
*/
|
|
196
|
+
isValidUtxoStructure (utxo) {
|
|
197
|
+
return (
|
|
198
|
+
utxo &&
|
|
199
|
+
utxo.outpoint &&
|
|
200
|
+
typeof utxo.outpoint.txid === 'string' &&
|
|
201
|
+
typeof utxo.outpoint.outIdx === 'number' &&
|
|
202
|
+
(utxo.sats !== undefined || utxo.value !== undefined) &&
|
|
203
|
+
(typeof utxo.blockHeight === 'number' || utxo.blockHeight === -1)
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Filter UTXOs to only safe ones
|
|
209
|
+
* @param {Array} utxos - UTXOs to filter
|
|
210
|
+
* @param {Object} options - Filtering options
|
|
211
|
+
* @returns {Array} - Filtered safe UTXOs
|
|
212
|
+
*/
|
|
213
|
+
filterSecureUtxos (utxos, options = {}) {
|
|
214
|
+
const {
|
|
215
|
+
includeUnconfirmed = false,
|
|
216
|
+
excludeDustAttack = true
|
|
217
|
+
} = options
|
|
218
|
+
|
|
219
|
+
let filteredUtxos = utxos.filter(utxo => {
|
|
220
|
+
// Basic security check with options
|
|
221
|
+
if (!this.isSecureUtxo(utxo, { includeUnconfirmed })) {
|
|
222
|
+
return false
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return true
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// Remove dust attack UTXOs if requested
|
|
229
|
+
if (excludeDustAttack) {
|
|
230
|
+
const dustAnalysis = this.analyzeDustAttack(filteredUtxos)
|
|
231
|
+
if (dustAnalysis.isDustAttack) {
|
|
232
|
+
filteredUtxos = filteredUtxos.filter((_, index) =>
|
|
233
|
+
!dustAnalysis.suspiciousUtxos.includes(index)
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return filteredUtxos
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Private helper methods
|
|
242
|
+
|
|
243
|
+
_extractSats (utxo) {
|
|
244
|
+
if (utxo.sats !== undefined) {
|
|
245
|
+
return typeof utxo.sats === 'bigint' ? Number(utxo.sats) : parseInt(utxo.sats)
|
|
246
|
+
}
|
|
247
|
+
if (utxo.value !== undefined) {
|
|
248
|
+
return typeof utxo.value === 'bigint' ? Number(utxo.value) : parseInt(utxo.value)
|
|
249
|
+
}
|
|
250
|
+
throw new Error('No sats/value found in UTXO')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_validateAmount (amount) {
|
|
254
|
+
if (typeof amount === 'string') {
|
|
255
|
+
amount = parseInt(amount)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (typeof amount !== 'number' || isNaN(amount)) {
|
|
259
|
+
return null
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (amount <= 0 || amount > Number.MAX_SAFE_INTEGER) {
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return amount
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = SecurityValidator
|