1 | import { TxOutput, address as bjsAddress, networks, crypto as bjsCrypto, Transaction, payments, Network } from 'bitcoinjs-lib'
|
2 | import FormData from 'form-data'
|
3 | import BN from 'bn.js'
|
4 | import RIPEMD160 from 'ripemd160'
|
5 | import { MissingParameterError, RemoteServiceError } from './errors'
|
6 | import { Logger } from './logger'
|
7 | import { config } from './config'
|
8 | import { fetchPrivate } from './fetchUtil'
|
9 |
|
10 |
|
11 |
|
12 |
|
13 | export type UTXO = {
|
14 | value?: number,
|
15 | confirmations?: number,
|
16 | tx_hash: string,
|
17 | tx_output_n: number
|
18 | }
|
19 |
|
20 | const SATOSHIS_PER_BTC = 1e8
|
21 | const TX_BROADCAST_SERVICE_ZONE_FILE_ENDPOINT = 'zone-file'
|
22 | const TX_BROADCAST_SERVICE_REGISTRATION_ENDPOINT = 'registration'
|
23 | const TX_BROADCAST_SERVICE_TX_ENDPOINT = 'transaction'
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 | export class BitcoinNetwork {
|
30 | broadcastTransaction(transaction: string): Promise<any> {
|
31 | return Promise.reject(new Error(`Not implemented, broadcastTransaction(${transaction})`))
|
32 | }
|
33 |
|
34 | getBlockHeight(): Promise<number> {
|
35 | return Promise.reject(new Error('Not implemented, getBlockHeight()'))
|
36 | }
|
37 |
|
38 | getTransactionInfo(txid: string): Promise<{block_height: number}> {
|
39 | return Promise.reject(new Error(`Not implemented, getTransactionInfo(${txid})`))
|
40 | }
|
41 |
|
42 | getNetworkedUTXOs(address: string): Promise<Array<UTXO>> {
|
43 | return Promise.reject(new Error(`Not implemented, getNetworkedUTXOs(${address})`))
|
44 | }
|
45 | }
|
46 |
|
47 |
|
48 |
|
49 |
|
50 |
|
51 | export class BlockstackNetwork {
|
52 | blockstackAPIUrl: string
|
53 |
|
54 | broadcastServiceUrl: string
|
55 |
|
56 | layer1: Network
|
57 |
|
58 | DUST_MINIMUM: number
|
59 |
|
60 | includeUtxoMap: {[address: string]: UTXO[]}
|
61 |
|
62 | excludeUtxoSet: Array<UTXO>
|
63 |
|
64 | btc: BitcoinNetwork
|
65 |
|
66 | MAGIC_BYTES: string
|
67 |
|
68 | constructor(apiUrl: string, broadcastServiceUrl: string,
|
69 | bitcoinAPI: BitcoinNetwork,
|
70 | network = networks.bitcoin) {
|
71 | this.blockstackAPIUrl = apiUrl
|
72 | this.broadcastServiceUrl = broadcastServiceUrl
|
73 | this.layer1 = network
|
74 | this.btc = bitcoinAPI
|
75 |
|
76 | this.DUST_MINIMUM = 5500
|
77 | this.includeUtxoMap = {}
|
78 | this.excludeUtxoSet = []
|
79 | this.MAGIC_BYTES = 'id'
|
80 | }
|
81 |
|
82 | coerceAddress(address: string) {
|
83 | const { hash, version } = bjsAddress.fromBase58Check(address)
|
84 | const scriptHashes = [networks.bitcoin.scriptHash,
|
85 | networks.testnet.scriptHash]
|
86 | const pubKeyHashes = [networks.bitcoin.pubKeyHash,
|
87 | networks.testnet.pubKeyHash]
|
88 | let coercedVersion
|
89 | if (scriptHashes.indexOf(version) >= 0) {
|
90 | coercedVersion = this.layer1.scriptHash
|
91 | } else if (pubKeyHashes.indexOf(version) >= 0) {
|
92 | coercedVersion = this.layer1.pubKeyHash
|
93 | } else {
|
94 | throw new Error(`Unrecognized address version number ${version} in ${address}`)
|
95 | }
|
96 | return bjsAddress.toBase58Check(hash, coercedVersion)
|
97 | }
|
98 |
|
99 | |
100 |
|
101 |
|
102 | getDefaultBurnAddress() {
|
103 | return this.coerceAddress('1111111111111111111114oLvT2')
|
104 | }
|
105 |
|
106 | |
107 |
|
108 |
|
109 |
|
110 |
|
111 |
|
112 | getNamePriceV1(fullyQualifiedName: string): Promise<{units: string, amount: BN}> {
|
113 |
|
114 | return fetchPrivate(`${this.blockstackAPIUrl}/v1/prices/names/${fullyQualifiedName}`)
|
115 | .then((resp) => {
|
116 | if (!resp.ok) {
|
117 | throw new Error(`Failed to query name price for ${fullyQualifiedName}`)
|
118 | }
|
119 | return resp
|
120 | })
|
121 | .then(resp => resp.json())
|
122 | .then(resp => resp.name_price)
|
123 | .then((namePrice) => {
|
124 | if (!namePrice || !namePrice.satoshis) {
|
125 | throw new Error(
|
126 | `Failed to get price for ${fullyQualifiedName}. Does the namespace exist?`
|
127 | )
|
128 | }
|
129 | if (namePrice.satoshis < this.DUST_MINIMUM) {
|
130 | namePrice.satoshis = this.DUST_MINIMUM
|
131 | }
|
132 | const result = {
|
133 | units: 'BTC',
|
134 | amount: new BN(String(namePrice.satoshis))
|
135 | }
|
136 | return result
|
137 | })
|
138 | }
|
139 |
|
140 | |
141 |
|
142 |
|
143 |
|
144 |
|
145 |
|
146 | getNamespacePriceV1(namespaceID: string): Promise<{units: string, amount: BN}> {
|
147 |
|
148 | return fetchPrivate(`${this.blockstackAPIUrl}/v1/prices/namespaces/${namespaceID}`)
|
149 | .then((resp) => {
|
150 | if (!resp.ok) {
|
151 | throw new Error(`Failed to query name price for ${namespaceID}`)
|
152 | }
|
153 | return resp
|
154 | })
|
155 | .then(resp => resp.json())
|
156 | .then((namespacePrice) => {
|
157 | if (!namespacePrice || !namespacePrice.satoshis) {
|
158 | throw new Error(`Failed to get price for ${namespaceID}`)
|
159 | }
|
160 | if (namespacePrice.satoshis < this.DUST_MINIMUM) {
|
161 | namespacePrice.satoshis = this.DUST_MINIMUM
|
162 | }
|
163 | const result = {
|
164 | units: 'BTC',
|
165 | amount: new BN(String(namespacePrice.satoshis))
|
166 | }
|
167 | return result
|
168 | })
|
169 | }
|
170 |
|
171 | |
172 |
|
173 |
|
174 |
|
175 |
|
176 |
|
177 | getNamePriceV2(fullyQualifiedName: string): Promise<{units: string, amount: BN}> {
|
178 | return fetchPrivate(`${this.blockstackAPIUrl}/v2/prices/names/${fullyQualifiedName}`)
|
179 | .then((resp) => {
|
180 | if (resp.status !== 200) {
|
181 |
|
182 | throw new Error('The upstream node does not handle the /v2/ price namespace')
|
183 | }
|
184 | return resp
|
185 | })
|
186 | .then(resp => resp.json())
|
187 | .then(resp => resp.name_price)
|
188 | .then((namePrice) => {
|
189 | if (!namePrice) {
|
190 | throw new Error(
|
191 | `Failed to get price for ${fullyQualifiedName}. Does the namespace exist?`
|
192 | )
|
193 | }
|
194 | const result = {
|
195 | units: namePrice.units,
|
196 | amount: new BN(namePrice.amount)
|
197 | }
|
198 | if (namePrice.units === 'BTC') {
|
199 |
|
200 | const dustMin = new BN(String(this.DUST_MINIMUM))
|
201 | if (result.amount.ucmp(dustMin) < 0) {
|
202 | result.amount = dustMin
|
203 | }
|
204 | }
|
205 | return result
|
206 | })
|
207 | }
|
208 |
|
209 | |
210 |
|
211 |
|
212 |
|
213 |
|
214 |
|
215 | getNamespacePriceV2(namespaceID: string): Promise<{units: string, amount: BN}> {
|
216 | return fetchPrivate(`${this.blockstackAPIUrl}/v2/prices/namespaces/${namespaceID}`)
|
217 | .then((resp) => {
|
218 | if (resp.status !== 200) {
|
219 |
|
220 | throw new Error('The upstream node does not handle the /v2/ price namespace')
|
221 | }
|
222 | return resp
|
223 | })
|
224 | .then(resp => resp.json())
|
225 | .then((namespacePrice) => {
|
226 | if (!namespacePrice) {
|
227 | throw new Error(`Failed to get price for ${namespaceID}`)
|
228 | }
|
229 | const result = {
|
230 | units: namespacePrice.units,
|
231 | amount: new BN(namespacePrice.amount)
|
232 | }
|
233 | if (namespacePrice.units === 'BTC') {
|
234 |
|
235 | const dustMin = new BN(String(this.DUST_MINIMUM))
|
236 | if (result.amount.ucmp(dustMin) < 0) {
|
237 | result.amount = dustMin
|
238 | }
|
239 | }
|
240 | return result
|
241 | })
|
242 | }
|
243 |
|
244 | |
245 |
|
246 |
|
247 |
|
248 |
|
249 |
|
250 |
|
251 |
|
252 |
|
253 | getNamePrice(fullyQualifiedName: string): Promise<{units: string, amount: BN}> {
|
254 |
|
255 | return Promise.resolve().then(() => this.getNamePriceV2(fullyQualifiedName))
|
256 | .catch(() => this.getNamePriceV1(fullyQualifiedName))
|
257 | }
|
258 |
|
259 | |
260 |
|
261 |
|
262 |
|
263 |
|
264 |
|
265 |
|
266 |
|
267 |
|
268 | getNamespacePrice(namespaceID: string): Promise<{units: string, amount: BN}> {
|
269 |
|
270 | return Promise.resolve().then(() => this.getNamespacePriceV2(namespaceID))
|
271 | .catch(() => this.getNamespacePriceV1(namespaceID))
|
272 | }
|
273 |
|
274 | |
275 |
|
276 |
|
277 |
|
278 |
|
279 |
|
280 | getGracePeriod(fullyQualifiedName?: string) {
|
281 | return Promise.resolve(5000)
|
282 | }
|
283 |
|
284 | |
285 |
|
286 |
|
287 |
|
288 |
|
289 | getNamesOwned(address: string): Promise<string[]> {
|
290 | const networkAddress = this.coerceAddress(address)
|
291 | return fetchPrivate(`${this.blockstackAPIUrl}/v1/addresses/bitcoin/${networkAddress}`)
|
292 | .then(resp => resp.json())
|
293 | .then(obj => obj.names)
|
294 | }
|
295 |
|
296 | |
297 |
|
298 |
|
299 |
|
300 |
|
301 |
|
302 | getNamespaceBurnAddress(namespace: string) {
|
303 | return Promise.all([
|
304 | fetchPrivate(`${this.blockstackAPIUrl}/v1/namespaces/${namespace}`),
|
305 | this.getBlockHeight()
|
306 | ])
|
307 | .then(([resp, blockHeight]) => {
|
308 | if (resp.status === 404) {
|
309 | throw new Error(`No such namespace '${namespace}'`)
|
310 | } else {
|
311 | return Promise.all([resp.json(), blockHeight])
|
312 | }
|
313 | })
|
314 | .then(([namespaceInfo, blockHeight]) => {
|
315 | let address = this.getDefaultBurnAddress()
|
316 | if (namespaceInfo.version === 2) {
|
317 |
|
318 | if (namespaceInfo.reveal_block + 52595 >= blockHeight) {
|
319 | address = namespaceInfo.address
|
320 | }
|
321 | }
|
322 | return address
|
323 | })
|
324 | .then(address => this.coerceAddress(address))
|
325 | }
|
326 |
|
327 | |
328 |
|
329 |
|
330 |
|
331 |
|
332 |
|
333 | getNameInfo(fullyQualifiedName: string) {
|
334 | Logger.debug(this.blockstackAPIUrl)
|
335 | const nameLookupURL = `${this.blockstackAPIUrl}/v1/names/${fullyQualifiedName}`
|
336 | return fetchPrivate(nameLookupURL)
|
337 | .then((resp) => {
|
338 | if (resp.status === 404) {
|
339 | throw new Error('Name not found')
|
340 | } else if (resp.status !== 200) {
|
341 | throw new Error(`Bad response status: ${resp.status}`)
|
342 | } else {
|
343 | return resp.json()
|
344 | }
|
345 | })
|
346 | .then((nameInfo) => {
|
347 | Logger.debug(`nameInfo: ${JSON.stringify(nameInfo)}`)
|
348 |
|
349 |
|
350 |
|
351 | if (nameInfo.address) {
|
352 | return Object.assign({}, nameInfo, { address: this.coerceAddress(nameInfo.address) })
|
353 | } else {
|
354 | return nameInfo
|
355 | }
|
356 | })
|
357 | }
|
358 |
|
359 | |
360 |
|
361 |
|
362 |
|
363 |
|
364 | getNamespaceInfo(namespaceID: string) {
|
365 | return fetchPrivate(`${this.blockstackAPIUrl}/v1/namespaces/${namespaceID}`)
|
366 | .then((resp) => {
|
367 | if (resp.status === 404) {
|
368 | throw new Error('Namespace not found')
|
369 | } else if (resp.status !== 200) {
|
370 | throw new Error(`Bad response status: ${resp.status}`)
|
371 | } else {
|
372 | return resp.json()
|
373 | }
|
374 | })
|
375 | .then((namespaceInfo) => {
|
376 |
|
377 |
|
378 |
|
379 | if (namespaceInfo.address && namespaceInfo.recipient_address) {
|
380 | return Object.assign({}, namespaceInfo, {
|
381 | address: this.coerceAddress(namespaceInfo.address),
|
382 | recipient_address: this.coerceAddress(namespaceInfo.recipient_address)
|
383 | })
|
384 | } else {
|
385 | return namespaceInfo
|
386 | }
|
387 | })
|
388 | }
|
389 |
|
390 | |
391 |
|
392 |
|
393 |
|
394 |
|
395 |
|
396 | getZonefile(zonefileHash: string) {
|
397 | return fetchPrivate(`${this.blockstackAPIUrl}/v1/zonefiles/${zonefileHash}`)
|
398 | .then((resp) => {
|
399 | if (resp.status === 200) {
|
400 | return resp.text()
|
401 | .then((body) => {
|
402 | const sha256 = bjsCrypto.sha256(Buffer.from(body))
|
403 | const h = (new RIPEMD160()).update(sha256).digest('hex')
|
404 | if (h !== zonefileHash) {
|
405 | throw new Error(`Zone file contents hash to ${h}, not ${zonefileHash}`)
|
406 | }
|
407 | return body
|
408 | })
|
409 | } else {
|
410 | throw new Error(`Bad response status: ${resp.status}`)
|
411 | }
|
412 | })
|
413 | }
|
414 |
|
415 | |
416 |
|
417 |
|
418 |
|
419 |
|
420 |
|
421 |
|
422 |
|
423 | getAccountStatus(address: string, tokenType: string) {
|
424 | return fetchPrivate(`${this.blockstackAPIUrl}/v1/accounts/${address}/${tokenType}/status`)
|
425 | .then((resp) => {
|
426 | if (resp.status === 404) {
|
427 | throw new Error('Account not found')
|
428 | } else if (resp.status !== 200) {
|
429 | throw new Error(`Bad response status: ${resp.status}`)
|
430 | } else {
|
431 | return resp.json()
|
432 | }
|
433 | }).then((accountStatus) => {
|
434 |
|
435 | const formattedStatus = Object.assign({}, accountStatus, {
|
436 | address: this.coerceAddress(accountStatus.address),
|
437 | debit_value: new BN(String(accountStatus.debit_value)),
|
438 | credit_value: new BN(String(accountStatus.credit_value))
|
439 | })
|
440 | return formattedStatus
|
441 | })
|
442 | }
|
443 |
|
444 |
|
445 | |
446 |
|
447 |
|
448 |
|
449 |
|
450 |
|
451 |
|
452 | getAccountHistoryPage(address: string,
|
453 | page: number): Promise<any[]> {
|
454 | const url = `${this.blockstackAPIUrl}/v1/accounts/${address}/history?page=${page}`
|
455 | return fetchPrivate(url)
|
456 | .then((resp) => {
|
457 | if (resp.status === 404) {
|
458 | throw new Error('Account not found')
|
459 | } else if (resp.status !== 200) {
|
460 | throw new Error(`Bad response status: ${resp.status}`)
|
461 | } else {
|
462 | return resp.json()
|
463 | }
|
464 | })
|
465 | .then((historyList) => {
|
466 | if (historyList.error) {
|
467 | throw new Error(`Unable to get account history page: ${historyList.error}`)
|
468 | }
|
469 |
|
470 | return historyList.map((histEntry: any) => {
|
471 | histEntry.address = this.coerceAddress(histEntry.address)
|
472 | histEntry.debit_value = new BN(String(histEntry.debit_value))
|
473 | histEntry.credit_value = new BN(String(histEntry.credit_value))
|
474 | return histEntry
|
475 | })
|
476 | })
|
477 | }
|
478 |
|
479 | |
480 |
|
481 |
|
482 |
|
483 |
|
484 |
|
485 |
|
486 |
|
487 |
|
488 | getAccountAt(address: string, blockHeight: number): Promise<any[]> {
|
489 | const url = `${this.blockstackAPIUrl}/v1/accounts/${address}/history/${blockHeight}`
|
490 | return fetchPrivate(url)
|
491 | .then((resp) => {
|
492 | if (resp.status === 404) {
|
493 | throw new Error('Account not found')
|
494 | } else if (resp.status !== 200) {
|
495 | throw new Error(`Bad response status: ${resp.status}`)
|
496 | } else {
|
497 | return resp.json()
|
498 | }
|
499 | })
|
500 | .then((historyList) => {
|
501 | if (historyList.error) {
|
502 | throw new Error(`Unable to get historic account state: ${historyList.error}`)
|
503 | }
|
504 |
|
505 | return historyList.map((histEntry: any) => {
|
506 | histEntry.address = this.coerceAddress(histEntry.address)
|
507 | histEntry.debit_value = new BN(String(histEntry.debit_value))
|
508 | histEntry.credit_value = new BN(String(histEntry.credit_value))
|
509 | return histEntry
|
510 | })
|
511 | })
|
512 | }
|
513 |
|
514 | |
515 |
|
516 |
|
517 |
|
518 |
|
519 |
|
520 | getAccountTokens(address: string): Promise<{tokens: string[]}> {
|
521 | return fetchPrivate(`${this.blockstackAPIUrl}/v1/accounts/${address}/tokens`)
|
522 | .then((resp) => {
|
523 | if (resp.status === 404) {
|
524 | throw new Error('Account not found')
|
525 | } else if (resp.status !== 200) {
|
526 | throw new Error(`Bad response status: ${resp.status}`)
|
527 | } else {
|
528 | return resp.json()
|
529 | }
|
530 | })
|
531 | .then((tokenList) => {
|
532 | if (tokenList.error) {
|
533 | throw new Error(`Unable to get token list: ${tokenList.error}`)
|
534 | }
|
535 | return tokenList
|
536 | })
|
537 | }
|
538 |
|
539 | |
540 |
|
541 |
|
542 |
|
543 |
|
544 |
|
545 |
|
546 |
|
547 | getAccountBalance(address: string, tokenType: string): Promise<BN> {
|
548 | return fetchPrivate(`${this.blockstackAPIUrl}/v1/accounts/${address}/${tokenType}/balance`)
|
549 | .then((resp) => {
|
550 | if (resp.status === 404) {
|
551 |
|
552 | return Promise.resolve().then(() => new BN('0'))
|
553 | } else if (resp.status !== 200) {
|
554 | throw new Error(`Bad response status: ${resp.status}`)
|
555 | } else {
|
556 | return resp.json()
|
557 | }
|
558 | })
|
559 | .then((tokenBalance) => {
|
560 | if (tokenBalance.error) {
|
561 | throw new Error(`Unable to get account balance: ${tokenBalance.error}`)
|
562 | }
|
563 | let balance = '0'
|
564 | if (tokenBalance && tokenBalance.balance) {
|
565 | balance = tokenBalance.balance
|
566 | }
|
567 | return new BN(balance)
|
568 | })
|
569 | }
|
570 |
|
571 |
|
572 | |
573 |
|
574 |
|
575 |
|
576 |
|
577 |
|
578 |
|
579 |
|
580 |
|
581 |
|
582 |
|
583 |
|
584 |
|
585 | broadcastServiceFetchHelper(endpoint: string, body: any): Promise<any|Error> {
|
586 | const requestHeaders = {
|
587 | Accept: 'application/json',
|
588 | 'Content-Type': 'application/json'
|
589 | }
|
590 |
|
591 | const options = {
|
592 | method: 'POST',
|
593 | headers: requestHeaders,
|
594 | body: JSON.stringify(body)
|
595 | }
|
596 |
|
597 | const url = `${this.broadcastServiceUrl}/v1/broadcast/${endpoint}`
|
598 | return fetchPrivate(url, options)
|
599 | .then((response) => {
|
600 | if (response.ok) {
|
601 | return response.json()
|
602 | } else {
|
603 | throw new RemoteServiceError(response)
|
604 | }
|
605 | })
|
606 | }
|
607 |
|
608 | |
609 |
|
610 |
|
611 |
|
612 |
|
613 |
|
614 |
|
615 |
|
616 |
|
617 |
|
618 |
|
619 |
|
620 |
|
621 |
|
622 |
|
623 |
|
624 |
|
625 |
|
626 |
|
627 | broadcastTransaction(
|
628 | transaction: string,
|
629 | transactionToWatch: string = null,
|
630 | confirmations: number = 6
|
631 | ) {
|
632 | if (!transaction) {
|
633 | const error = new MissingParameterError('transaction')
|
634 | return Promise.reject(error)
|
635 | }
|
636 |
|
637 | if (!confirmations && confirmations !== 0) {
|
638 | const error = new MissingParameterError('confirmations')
|
639 | return Promise.reject(error)
|
640 | }
|
641 |
|
642 | if (transactionToWatch === null) {
|
643 | return this.btc.broadcastTransaction(transaction)
|
644 | } else {
|
645 | |
646 |
|
647 |
|
648 |
|
649 |
|
650 |
|
651 |
|
652 |
|
653 |
|
654 | const endpoint = TX_BROADCAST_SERVICE_TX_ENDPOINT
|
655 |
|
656 | const requestBody = {
|
657 | transaction,
|
658 | transactionToWatch,
|
659 | confirmations
|
660 | }
|
661 |
|
662 | return this.broadcastServiceFetchHelper(endpoint, requestBody)
|
663 | }
|
664 | }
|
665 |
|
666 | |
667 |
|
668 |
|
669 |
|
670 |
|
671 |
|
672 |
|
673 |
|
674 |
|
675 |
|
676 |
|
677 |
|
678 |
|
679 |
|
680 |
|
681 |
|
682 | broadcastZoneFile(
|
683 | zoneFile?: string,
|
684 | transactionToWatch: string = null
|
685 | ) {
|
686 | if (!zoneFile) {
|
687 | return Promise.reject(new MissingParameterError('zoneFile'))
|
688 | }
|
689 |
|
690 |
|
691 |
|
692 | if (transactionToWatch) {
|
693 |
|
694 |
|
695 | |
696 |
|
697 |
|
698 |
|
699 |
|
700 |
|
701 |
|
702 |
|
703 |
|
704 | const requestBody = {
|
705 | zoneFile,
|
706 | transactionToWatch
|
707 | }
|
708 |
|
709 | const endpoint = TX_BROADCAST_SERVICE_ZONE_FILE_ENDPOINT
|
710 |
|
711 | return this.broadcastServiceFetchHelper(endpoint, requestBody)
|
712 | } else {
|
713 |
|
714 |
|
715 |
|
716 | const requestBody = { zonefile: zoneFile }
|
717 |
|
718 | return fetchPrivate(`${this.blockstackAPIUrl}/v1/zonefile/`,
|
719 | {
|
720 | method: 'POST',
|
721 | body: JSON.stringify(requestBody),
|
722 | headers: {
|
723 | 'Content-Type': 'application/json'
|
724 | }
|
725 | })
|
726 | .then((resp) => {
|
727 | const json = resp.json()
|
728 | return json
|
729 | .then((respObj) => {
|
730 | if (respObj.hasOwnProperty('error')) {
|
731 | throw new RemoteServiceError(resp)
|
732 | }
|
733 | return respObj.servers
|
734 | })
|
735 | })
|
736 | }
|
737 | }
|
738 |
|
739 | |
740 |
|
741 |
|
742 |
|
743 |
|
744 |
|
745 |
|
746 |
|
747 |
|
748 |
|
749 |
|
750 |
|
751 |
|
752 |
|
753 |
|
754 |
|
755 |
|
756 |
|
757 |
|
758 |
|
759 |
|
760 |
|
761 |
|
762 |
|
763 |
|
764 |
|
765 |
|
766 |
|
767 | broadcastNameRegistration(
|
768 | preorderTransaction: string,
|
769 | registerTransaction: string,
|
770 | zoneFile: string
|
771 | ) {
|
772 | |
773 |
|
774 |
|
775 |
|
776 |
|
777 |
|
778 |
|
779 |
|
780 |
|
781 |
|
782 | if (!preorderTransaction) {
|
783 | const error = new MissingParameterError('preorderTransaction')
|
784 | return Promise.reject(error)
|
785 | }
|
786 |
|
787 | if (!registerTransaction) {
|
788 | const error = new MissingParameterError('registerTransaction')
|
789 | return Promise.reject(error)
|
790 | }
|
791 |
|
792 | if (!zoneFile) {
|
793 | const error = new MissingParameterError('zoneFile')
|
794 | return Promise.reject(error)
|
795 | }
|
796 |
|
797 | const requestBody = {
|
798 | preorderTransaction,
|
799 | registerTransaction,
|
800 | zoneFile
|
801 | }
|
802 |
|
803 | const endpoint = TX_BROADCAST_SERVICE_REGISTRATION_ENDPOINT
|
804 |
|
805 | return this.broadcastServiceFetchHelper(endpoint, requestBody)
|
806 | }
|
807 |
|
808 | |
809 |
|
810 |
|
811 | getFeeRate(): Promise<number> {
|
812 | return fetchPrivate('https://bitcoinfees.earn.com/api/v1/fees/recommended')
|
813 | .then(resp => resp.json())
|
814 | .then(rates => Math.floor(rates.fastestFee))
|
815 | }
|
816 |
|
817 | |
818 |
|
819 |
|
820 | countDustOutputs() {
|
821 | throw new Error('Not implemented.')
|
822 | }
|
823 |
|
824 | |
825 |
|
826 |
|
827 | getUTXOs(address: string): Promise<Array<UTXO>> {
|
828 | return this.getNetworkedUTXOs(address)
|
829 | .then((networkedUTXOs) => {
|
830 | let returnSet = networkedUTXOs.concat()
|
831 | if (this.includeUtxoMap.hasOwnProperty(address)) {
|
832 | returnSet = networkedUTXOs.concat(this.includeUtxoMap[address])
|
833 | }
|
834 |
|
835 |
|
836 |
|
837 | const excludeSet = this.excludeUtxoSet
|
838 | returnSet = returnSet.filter(
|
839 | (utxo) => {
|
840 | const inExcludeSet = excludeSet.reduce(
|
841 | (inSet, utxoToCheck) => inSet || (utxoToCheck.tx_hash === utxo.tx_hash
|
842 | && utxoToCheck.tx_output_n === utxo.tx_output_n), false
|
843 | )
|
844 | return !inExcludeSet
|
845 | }
|
846 | )
|
847 |
|
848 | return returnSet
|
849 | })
|
850 | }
|
851 |
|
852 | |
853 |
|
854 |
|
855 |
|
856 |
|
857 |
|
858 |
|
859 |
|
860 |
|
861 | modifyUTXOSetFrom(txHex: string) {
|
862 | const tx = Transaction.fromHex(txHex)
|
863 |
|
864 | const excludeSet: Array<UTXO> = this.excludeUtxoSet.concat()
|
865 |
|
866 | tx.ins.forEach((utxoUsed) => {
|
867 | const reverseHash = Buffer.from(utxoUsed.hash)
|
868 | reverseHash.reverse()
|
869 | excludeSet.push({
|
870 | tx_hash: reverseHash.toString('hex'),
|
871 | tx_output_n: utxoUsed.index
|
872 | })
|
873 | })
|
874 |
|
875 | this.excludeUtxoSet = excludeSet
|
876 |
|
877 | const txHash = Buffer.from(tx.getHash().reverse()).toString('hex')
|
878 | tx.outs.forEach((utxoCreated, txOutputN) => {
|
879 | const isNullData = function isNullData(script: Buffer) {
|
880 | try {
|
881 | payments.embed({ output: script }, { validate: true })
|
882 | return true
|
883 | } catch (_) {
|
884 | return false
|
885 | }
|
886 | }
|
887 | if (isNullData(utxoCreated.script)) {
|
888 | return
|
889 | }
|
890 | const address = bjsAddress.fromOutputScript(
|
891 | utxoCreated.script, this.layer1
|
892 | )
|
893 |
|
894 | let includeSet: UTXO[] = []
|
895 | if (this.includeUtxoMap.hasOwnProperty(address)) {
|
896 | includeSet = includeSet.concat(this.includeUtxoMap[address])
|
897 | }
|
898 |
|
899 | includeSet.push({
|
900 | tx_hash: txHash,
|
901 | confirmations: 0,
|
902 | value: (utxoCreated as TxOutput).value,
|
903 | tx_output_n: txOutputN
|
904 | })
|
905 | this.includeUtxoMap[address] = includeSet
|
906 | })
|
907 | }
|
908 |
|
909 | resetUTXOs(address: string) {
|
910 | delete this.includeUtxoMap[address]
|
911 | this.excludeUtxoSet = []
|
912 | }
|
913 |
|
914 | |
915 |
|
916 |
|
917 | getConsensusHash() {
|
918 | return fetchPrivate(`${this.blockstackAPIUrl}/v1/blockchains/bitcoin/consensus`)
|
919 | .then(resp => resp.json())
|
920 | .then(x => x.consensus_hash)
|
921 | }
|
922 |
|
923 | getTransactionInfo(txHash: string): Promise<{block_height: number}> {
|
924 | return this.btc.getTransactionInfo(txHash)
|
925 | }
|
926 |
|
927 | |
928 |
|
929 |
|
930 | getBlockHeight() {
|
931 | return this.btc.getBlockHeight()
|
932 | }
|
933 |
|
934 | getNetworkedUTXOs(address: string): Promise<Array<UTXO>> {
|
935 | return this.btc.getNetworkedUTXOs(address)
|
936 | }
|
937 | }
|
938 |
|
939 |
|
940 |
|
941 |
|
942 | export class LocalRegtest extends BlockstackNetwork {
|
943 | constructor(apiUrl: string, broadcastServiceUrl: string,
|
944 | bitcoinAPI: BitcoinNetwork) {
|
945 | super(apiUrl, broadcastServiceUrl, bitcoinAPI, networks.testnet)
|
946 | }
|
947 |
|
948 | getFeeRate(): Promise<number> {
|
949 | return Promise.resolve(Math.floor(0.00001000 * SATOSHIS_PER_BTC))
|
950 | }
|
951 | }
|
952 |
|
953 |
|
954 |
|
955 |
|
956 | export class BitcoindAPI extends BitcoinNetwork {
|
957 | bitcoindUrl: string
|
958 |
|
959 | bitcoindCredentials: {username: string, password: string}
|
960 |
|
961 | importedBefore: any
|
962 |
|
963 | constructor(bitcoindUrl: string, bitcoindCredentials: {username: string, password: string}) {
|
964 | super()
|
965 | this.bitcoindUrl = bitcoindUrl
|
966 | this.bitcoindCredentials = bitcoindCredentials
|
967 | this.importedBefore = {}
|
968 | }
|
969 |
|
970 | broadcastTransaction(transaction: string) {
|
971 | const jsonRPC = {
|
972 | jsonrpc: '1.0',
|
973 | method: 'sendrawtransaction',
|
974 | params: [transaction]
|
975 | }
|
976 | const authString = Buffer.from(`${this.bitcoindCredentials.username}:${this.bitcoindCredentials.password}`)
|
977 | .toString('base64')
|
978 | const headers = { Authorization: `Basic ${authString}` }
|
979 | return fetchPrivate(this.bitcoindUrl, {
|
980 | method: 'POST',
|
981 | body: JSON.stringify(jsonRPC),
|
982 | headers
|
983 | })
|
984 | .then(resp => resp.json())
|
985 | .then(respObj => respObj.result)
|
986 | }
|
987 |
|
988 | getBlockHeight() {
|
989 | const jsonRPC = {
|
990 | jsonrpc: '1.0',
|
991 | method: 'getblockcount'
|
992 | }
|
993 | const authString = Buffer.from(`${this.bitcoindCredentials.username}:${this.bitcoindCredentials.password}`)
|
994 | .toString('base64')
|
995 | const headers = { Authorization: `Basic ${authString}` }
|
996 | return fetchPrivate(this.bitcoindUrl, {
|
997 | method: 'POST',
|
998 | body: JSON.stringify(jsonRPC),
|
999 | headers
|
1000 | })
|
1001 | .then(resp => resp.json())
|
1002 | .then(respObj => respObj.result)
|
1003 | }
|
1004 |
|
1005 | getTransactionInfo(txHash: string): Promise<{block_height: number}> {
|
1006 | const jsonRPC = {
|
1007 | jsonrpc: '1.0',
|
1008 | method: 'gettransaction',
|
1009 | params: [txHash]
|
1010 | }
|
1011 | const authString = Buffer.from(`${this.bitcoindCredentials.username}:${this.bitcoindCredentials.password}`)
|
1012 | .toString('base64')
|
1013 | const headers = { Authorization: `Basic ${authString}` }
|
1014 | return fetchPrivate(this.bitcoindUrl, {
|
1015 | method: 'POST',
|
1016 | body: JSON.stringify(jsonRPC),
|
1017 | headers
|
1018 | })
|
1019 | .then(resp => resp.json())
|
1020 | .then(respObj => respObj.result)
|
1021 | .then(txInfo => txInfo.blockhash)
|
1022 | .then((blockhash) => {
|
1023 | const jsonRPCBlock = {
|
1024 | jsonrpc: '1.0',
|
1025 | method: 'getblockheader',
|
1026 | params: [blockhash]
|
1027 | }
|
1028 | headers.Authorization = `Basic ${authString}`
|
1029 | return fetchPrivate(this.bitcoindUrl, {
|
1030 | method: 'POST',
|
1031 | body: JSON.stringify(jsonRPCBlock),
|
1032 | headers
|
1033 | })
|
1034 | })
|
1035 | .then(resp => resp.json())
|
1036 | .then((respObj) => {
|
1037 | if (!respObj || !respObj.result) {
|
1038 |
|
1039 | throw new Error('Unconfirmed transaction')
|
1040 | } else {
|
1041 | return { block_height: respObj.result.height }
|
1042 | }
|
1043 | })
|
1044 | }
|
1045 |
|
1046 | getNetworkedUTXOs(address: string): Promise<Array<UTXO>> {
|
1047 | const jsonRPCImport = {
|
1048 | jsonrpc: '1.0',
|
1049 | method: 'importaddress',
|
1050 | params: [address]
|
1051 | }
|
1052 | const jsonRPCUnspent = {
|
1053 | jsonrpc: '1.0',
|
1054 | method: 'listunspent',
|
1055 | params: [0, 9999999, [address]]
|
1056 | }
|
1057 | const authString = Buffer.from(`${this.bitcoindCredentials.username}:${this.bitcoindCredentials.password}`)
|
1058 | .toString('base64')
|
1059 | const headers = { Authorization: `Basic ${authString}` }
|
1060 |
|
1061 | const importPromise = (this.importedBefore[address])
|
1062 | ? Promise.resolve()
|
1063 | : fetchPrivate(this.bitcoindUrl, {
|
1064 | method: 'POST',
|
1065 | body: JSON.stringify(jsonRPCImport),
|
1066 | headers
|
1067 | })
|
1068 | .then(() => { this.importedBefore[address] = true })
|
1069 |
|
1070 | return importPromise
|
1071 | .then(() => fetchPrivate(this.bitcoindUrl, {
|
1072 | method: 'POST',
|
1073 | body: JSON.stringify(jsonRPCUnspent),
|
1074 | headers
|
1075 | }))
|
1076 | .then(resp => resp.json())
|
1077 | .then(x => x.result)
|
1078 | .then(utxos => utxos.map(
|
1079 | (x: any) => ({
|
1080 | value: Math.round(x.amount * SATOSHIS_PER_BTC),
|
1081 | confirmations: x.confirmations,
|
1082 | tx_hash: x.txid,
|
1083 | tx_output_n: x.vout
|
1084 | })
|
1085 | ))
|
1086 | }
|
1087 | }
|
1088 |
|
1089 |
|
1090 |
|
1091 |
|
1092 | export class InsightClient extends BitcoinNetwork {
|
1093 | apiUrl: string
|
1094 |
|
1095 | constructor(insightUrl: string = 'https:
|
1096 | super()
|
1097 | this.apiUrl = insightUrl
|
1098 | }
|
1099 |
|
1100 | broadcastTransaction(transaction: string) {
|
1101 | const jsonData = { rawtx: transaction }
|
1102 | return fetchPrivate(`${this.apiUrl}/tx/send`,
|
1103 | {
|
1104 | method: 'POST',
|
1105 | headers: { 'Content-Type': 'application/json' },
|
1106 | body: JSON.stringify(jsonData)
|
1107 | })
|
1108 | .then(resp => resp.json())
|
1109 | }
|
1110 |
|
1111 | getBlockHeight() {
|
1112 | return fetchPrivate(`${this.apiUrl}/status`)
|
1113 | .then(resp => resp.json())
|
1114 | .then(status => status.blocks)
|
1115 | }
|
1116 |
|
1117 | getTransactionInfo(txHash: string): Promise<{block_height: number}> {
|
1118 | return fetchPrivate(`${this.apiUrl}/tx/${txHash}`)
|
1119 | .then(resp => resp.json())
|
1120 | .then((transactionInfo) => {
|
1121 | if (transactionInfo.error) {
|
1122 | throw new Error(`Error finding transaction: ${transactionInfo.error}`)
|
1123 | }
|
1124 | return fetchPrivate(`${this.apiUrl}/block/${transactionInfo.blockHash}`)
|
1125 | })
|
1126 | .then(resp => resp.json())
|
1127 | .then(blockInfo => ({ block_height: blockInfo.height }))
|
1128 | }
|
1129 |
|
1130 | getNetworkedUTXOs(address: string): Promise<Array<UTXO>> {
|
1131 | return fetchPrivate(`${this.apiUrl}/addr/${address}/utxo`)
|
1132 | .then(resp => resp.json())
|
1133 | .then(utxos => utxos.map(
|
1134 | (x: any) => ({
|
1135 | value: x.satoshis,
|
1136 | confirmations: x.confirmations,
|
1137 | tx_hash: x.txid,
|
1138 | tx_output_n: x.vout
|
1139 | })
|
1140 | ))
|
1141 | }
|
1142 | }
|
1143 |
|
1144 |
|
1145 |
|
1146 |
|
1147 |
|
1148 | export class BlockchainInfoApi extends BitcoinNetwork {
|
1149 | utxoProviderUrl: string
|
1150 |
|
1151 | constructor(blockchainInfoUrl: string = 'https://blockchain.info') {
|
1152 | super()
|
1153 | this.utxoProviderUrl = blockchainInfoUrl
|
1154 | }
|
1155 |
|
1156 | getBlockHeight() {
|
1157 | return fetchPrivate(`${this.utxoProviderUrl}/latestblock?cors=true`)
|
1158 | .then(resp => resp.json())
|
1159 | .then(blockObj => blockObj.height)
|
1160 | }
|
1161 |
|
1162 | getNetworkedUTXOs(address: string): Promise<Array<UTXO>> {
|
1163 | return fetchPrivate(`${this.utxoProviderUrl}/unspent?format=json&active=${address}&cors=true`)
|
1164 | .then((resp) => {
|
1165 | if (resp.status === 500) {
|
1166 | Logger.debug('UTXO provider 500 usually means no UTXOs: returning []')
|
1167 | return {
|
1168 | unspent_outputs: []
|
1169 | }
|
1170 | } else {
|
1171 | return resp.json()
|
1172 | }
|
1173 | })
|
1174 | .then(utxoJSON => utxoJSON.unspent_outputs)
|
1175 | .then(utxoList => utxoList.map(
|
1176 | (utxo: any) => {
|
1177 | const utxoOut = {
|
1178 | value: utxo.value,
|
1179 | tx_output_n: utxo.tx_output_n,
|
1180 | confirmations: utxo.confirmations,
|
1181 | tx_hash: utxo.tx_hash_big_endian
|
1182 | }
|
1183 | return utxoOut
|
1184 | }
|
1185 | ))
|
1186 | }
|
1187 |
|
1188 | getTransactionInfo(txHash: string): Promise<{block_height: number}> {
|
1189 | return fetchPrivate(`${this.utxoProviderUrl}/rawtx/${txHash}?cors=true`)
|
1190 | .then((resp) => {
|
1191 | if (resp.status === 200) {
|
1192 | return resp.json()
|
1193 | } else {
|
1194 | throw new Error(`Could not lookup transaction info for '${txHash}'. Server error.`)
|
1195 | }
|
1196 | })
|
1197 | .then(respObj => ({ block_height: respObj.block_height }))
|
1198 | }
|
1199 |
|
1200 | broadcastTransaction(transaction: string) {
|
1201 | const form = new FormData()
|
1202 | form.append('tx', transaction)
|
1203 | return fetchPrivate(`${this.utxoProviderUrl}/pushtx?cors=true`,
|
1204 | {
|
1205 | method: 'POST',
|
1206 | body: <any>form
|
1207 | })
|
1208 | .then((resp) => {
|
1209 | const text = resp.text()
|
1210 | return text
|
1211 | .then((respText) => {
|
1212 | if (respText.toLowerCase().indexOf('transaction submitted') >= 0) {
|
1213 | const txHash = Buffer.from(
|
1214 | Transaction.fromHex(transaction)
|
1215 | .getHash()
|
1216 | .reverse()).toString('hex') // big_endian
|
1217 | return txHash
|
1218 | } else {
|
1219 | throw new RemoteServiceError(resp,
|
1220 | `Broadcast transaction failed with message: ${respText}`)
|
1221 | }
|
1222 | })
|
1223 | })
|
1224 | }
|
1225 | }
|
1226 |
|
1227 |
|
1228 |
|
1229 |
|
1230 |
|
1231 | const LOCAL_REGTEST = new LocalRegtest(
|
1232 | 'http://localhost:16268',
|
1233 | 'http://localhost:16269',
|
1234 | new BitcoindAPI('http://localhost:18332/',
|
1235 | { username: 'blockstack', password: 'blockstacksystem' })
|
1236 | )
|
1237 |
|
1238 |
|
1239 |
|
1240 |
|
1241 | const MAINNET_DEFAULT = new BlockstackNetwork(
|
1242 | 'https://core.blockstack.org',
|
1243 | 'https://broadcast.blockstack.org',
|
1244 | new BlockchainInfoApi()
|
1245 | )
|
1246 |
|
1247 |
|
1248 |
|
1249 |
|
1250 |
|
1251 |
|
1252 |
|
1253 | export function getNameInfo(fullyQualifiedName: string) {
|
1254 | return config.network.getNameInfo(fullyQualifiedName)
|
1255 | }
|
1256 |
|
1257 |
|
1258 |
|
1259 |
|
1260 | export const network = {
|
1261 | BlockstackNetwork,
|
1262 | LocalRegtest,
|
1263 | BlockchainInfoApi,
|
1264 | BitcoindAPI,
|
1265 | InsightClient,
|
1266 | defaults: { LOCAL_REGTEST, MAINNET_DEFAULT }
|
1267 | }
|
1268 |
|
\ | No newline at end of file |