UNPKG

41.7 kBPlain TextView Raw
1import { TxOutput, address as bjsAddress, networks, crypto as bjsCrypto, Transaction, payments, Network } from 'bitcoinjs-lib'
2import FormData from 'form-data'
3import BN from 'bn.js'
4import RIPEMD160 from 'ripemd160'
5import { MissingParameterError, RemoteServiceError } from './errors'
6import { Logger } from './logger'
7import { config } from './config'
8import { fetchPrivate } from './fetchUtil'
9
10/**
11 * @ignore
12 */
13export type UTXO = {
14 value?: number,
15 confirmations?: number,
16 tx_hash: string,
17 tx_output_n: number
18}
19
20const SATOSHIS_PER_BTC = 1e8
21const TX_BROADCAST_SERVICE_ZONE_FILE_ENDPOINT = 'zone-file'
22const TX_BROADCAST_SERVICE_REGISTRATION_ENDPOINT = 'registration'
23const TX_BROADCAST_SERVICE_TX_ENDPOINT = 'transaction'
24
25/**
26 * @private
27 * @ignore
28 */
29export 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 * @private
49 * @ignore
50 */
51export 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 * @ignore
101 */
102 getDefaultBurnAddress() {
103 return this.coerceAddress('1111111111111111111114oLvT2')
104 }
105
106 /**
107 * Get the price of a name via the legacy /v1/prices API endpoint.
108 * @param {String} fullyQualifiedName the name to query
109 * @return {Promise} a promise to an Object with { units: String, amount: BigInteger }
110 * @private
111 */
112 getNamePriceV1(fullyQualifiedName: string): Promise<{units: string, amount: BN}> {
113 // legacy code path
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 * Get the price of a namespace via the legacy /v1/prices API endpoint.
142 * @param {String} namespaceID the namespace to query
143 * @return {Promise} a promise to an Object with { units: String, amount: BigInteger }
144 * @private
145 */
146 getNamespacePriceV1(namespaceID: string): Promise<{units: string, amount: BN}> {
147 // legacy code path
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 * Get the price of a name via the /v2/prices API endpoint.
173 * @param {String} fullyQualifiedName the name to query
174 * @return {Promise} a promise to an Object with { units: String, amount: BigInteger }
175 * @private
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 // old core node
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 // must be at least dust-minimum
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 * Get the price of a namespace via the /v2/prices API endpoint.
211 * @param {String} namespaceID the namespace to query
212 * @return {Promise} a promise to an Object with { units: String, amount: BigInteger }
213 * @private
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 // old core node
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 // must be at least dust-minimum
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 * Get the price of a name.
246 * @param {String} fullyQualifiedName the name to query
247 * @return {Promise} a promise to an Object with { units: String, amount: BigInteger }, where
248 * .units encodes the cryptocurrency units to pay (e.g. BTC, STACKS), and
249 * .amount encodes the number of units, in the smallest denominiated amount
250 * (e.g. if .units is BTC, .amount will be satoshis; if .units is STACKS,
251 * .amount will be microStacks)
252 */
253 getNamePrice(fullyQualifiedName: string): Promise<{units: string, amount: BN}> {
254 // handle v1 or v2
255 return Promise.resolve().then(() => this.getNamePriceV2(fullyQualifiedName))
256 .catch(() => this.getNamePriceV1(fullyQualifiedName))
257 }
258
259 /**
260 * Get the price of a namespace
261 * @param {String} namespaceID the namespace to query
262 * @return {Promise} a promise to an Object with { units: String, amount: BigInteger }, where
263 * .units encodes the cryptocurrency units to pay (e.g. BTC, STACKS), and
264 * .amount encodes the number of units, in the smallest denominiated amount
265 * (e.g. if .units is BTC, .amount will be satoshis; if .units is STACKS,
266 * .amount will be microStacks)
267 */
268 getNamespacePrice(namespaceID: string): Promise<{units: string, amount: BN}> {
269 // handle v1 or v2
270 return Promise.resolve().then(() => this.getNamespacePriceV2(namespaceID))
271 .catch(() => this.getNamespacePriceV1(namespaceID))
272 }
273
274 /**
275 * How many blocks can pass between a name expiring and the name being able to be
276 * re-registered by a different owner?
277 * @param {string} fullyQualifiedName unused
278 * @return {Promise} a promise to the number of blocks
279 */
280 getGracePeriod(fullyQualifiedName?: string) {
281 return Promise.resolve(5000)
282 }
283
284 /**
285 * Get the names -- both on-chain and off-chain -- owned by an address.
286 * @param {String} address the blockchain address (the hash of the owner public key)
287 * @return {Promise} a promise that resolves to a list of names (Strings)
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 * Get the blockchain address to which a name's registration fee must be sent
298 * (the address will depend on the namespace in which it is registered.)
299 * @param {String} namespace the namespace ID
300 * @return {Promise} a promise that resolves to an address (String)
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 // pay-to-namespace-creator if this namespace is less than 1 year old
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 * Get WHOIS-like information for a name, including the address that owns it,
329 * the block at which it expires, and the zone file anchored to it (if available).
330 * @param {String} fullyQualifiedName the name to query. Can be on-chain of off-chain.
331 * @return {Promise} a promise that resolves to the WHOIS-like information
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 // the returned address _should_ be in the correct network ---
349 // blockstackd gets into trouble because it tries to coerce back to mainnet
350 // and the regtest transaction generation libraries want to use testnet addresses
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 * Get the pricing parameters and creation history of a namespace.
361 * @param {String} namespaceID the namespace to query
362 * @return {Promise} a promise that resolves to the namespace information.
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 // the returned address _should_ be in the correct network ---
377 // blockstackd gets into trouble because it tries to coerce back to mainnet
378 // and the regtest transaction generation libraries want to use testnet addresses
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 * Get a zone file, given its hash. Throws an exception if the zone file
392 * obtained does not match the hash.
393 * @param {String} zonefileHash the ripemd160(sha256) hash of the zone file
394 * @return {Promise} a promise that resolves to the zone file's text
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 * Get the status of an account for a particular token holding. This includes its total number of
417 * expenditures and credits, lockup times, last txid, and so on.
418 * @param {String} address the account
419 * @param {String} tokenType the token type to query
420 * @return {Promise} a promise that resolves to an object representing the state of the account
421 * for this token
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 // coerce all addresses, and convert credit/debit to biginteger
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 * Get a page of an account's transaction history.
447 * @param {String} address the account's address
448 * @param {number} page the page number. Page 0 is the most recent transactions
449 * @return {Promise} a promise that resolves to an Array of Objects, where each Object encodes
450 * states of the account at various block heights (e.g. prior balances, txids, etc)
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 // coerse all addresses and convert to bigint
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 * Get the state(s) of an account at a particular block height. This includes the state of the
481 * account beginning with this block's transactions, as well as all of the states the account
482 * passed through when this block was processed (if any).
483 * @param {String} address the account's address
484 * @param {Integer} blockHeight the block to query
485 * @return {Promise} a promise that resolves to an Array of Objects, where each Object encodes
486 * states of the account at this block.
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 // coerce all addresses
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 * Get the set of token types that this account owns
516 * @param {String} address the account's address
517 * @return {Promise} a promise that resolves to an Array of Strings, where each item encodes the
518 * type of token this account holds (excluding the underlying blockchain's tokens)
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 * Get the number of tokens owned by an account. If the account does not exist or has no
541 * tokens of this type, then 0 will be returned.
542 * @param {String} address the account's address
543 * @param {String} tokenType the type of token to query.
544 * @return {Promise} a promise that resolves to a BigInteger that encodes the number of tokens
545 * held by this account.
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 // talking to an older blockstack core node without the accounts API
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 * Performs a POST request to the given URL
574 * @param {String} endpoint the name of
575 * @param {String} body [description]
576 * @return {Promise<Object|Error>} Returns a `Promise` that resolves to the object requested.
577 * In the event of an error, it rejects with:
578 * * a `RemoteServiceError` if there is a problem
579 * with the transaction broadcast service
580 * * `MissingParameterError` if you call the function without a required
581 * parameter
582 *
583 * @private
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 * Broadcasts a signed bitcoin transaction to the network optionally waiting to broadcast the
610 * transaction until a second transaction has a certain number of confirmations.
611 *
612 * @param {string} transaction the hex-encoded transaction to broadcast
613 * @param {string} transactionToWatch the hex transaction id of the transaction to watch for
614 * the specified number of confirmations before broadcasting the `transaction`
615 * @param {number} confirmations the number of confirmations `transactionToWatch` must have
616 * before broadcasting `transaction`.
617 * @return {Promise<Object|Error>} Returns a Promise that resolves to an object with a
618 * `transaction_hash` key containing the transaction hash of the broadcasted transaction.
619 *
620 * In the event of an error, it rejects with:
621 * * a `RemoteServiceError` if there is a problem
622 * with the transaction broadcast service
623 * * `MissingParameterError` if you call the function without a required
624 * parameter
625 * @private
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 * POST /v1/broadcast/transaction
647 * Request body:
648 * JSON.stringify({
649 * transaction,
650 * transactionToWatch,
651 * confirmations
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 * Broadcasts a zone file to the Atlas network via the transaction broadcast service.
668 *
669 * @param {String} zoneFile the zone file to be broadcast to the Atlas network
670 * @param {String} transactionToWatch the hex transaction id of the transaction
671 * to watch for confirmation before broadcasting the zone file to the Atlas network
672 * @return {Promise<Object|Error>} Returns a Promise that resolves to an object with a
673 * `transaction_hash` key containing the transaction hash of the broadcasted transaction.
674 *
675 * In the event of an error, it rejects with:
676 * * a `RemoteServiceError` if there is a problem
677 * with the transaction broadcast service
678 * * `MissingParameterError` if you call the function without a required
679 * parameter
680 * @private
681 */
682 broadcastZoneFile(
683 zoneFile?: string,
684 transactionToWatch: string = null
685 ) {
686 if (!zoneFile) {
687 return Promise.reject(new MissingParameterError('zoneFile'))
688 }
689
690 // TODO: validate zonefile
691
692 if (transactionToWatch) {
693 // broadcast via transaction broadcast service
694
695 /*
696 * POST /v1/broadcast/zone-file
697 * Request body:
698 * JSON.stringify({
699 * zoneFile,
700 * transactionToWatch
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 // broadcast via core endpoint
714
715 // zone file is two words but core's api treats it as one word 'zonefile'
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 * Sends the preorder and registration transactions and zone file
741 * for a Blockstack name registration
742 * along with the to the transaction broadcast service.
743 *
744 * The transaction broadcast:
745 *
746 * * immediately broadcasts the preorder transaction
747 * * broadcasts the register transactions after the preorder transaction
748 * has an appropriate number of confirmations
749 * * broadcasts the zone file to the Atlas network after the register transaction
750 * has an appropriate number of confirmations
751 *
752 * @param {String} preorderTransaction the hex-encoded, signed preorder transaction generated
753 * using the `makePreorder` function
754 * @param {String} registerTransaction the hex-encoded, signed register transaction generated
755 * using the `makeRegister` function
756 * @param {String} zoneFile the zone file to be broadcast to the Atlas network
757 * @return {Promise<Object|Error>} Returns a Promise that resolves to an object with a
758 * `transaction_hash` key containing the transaction hash of the broadcasted transaction.
759 *
760 * In the event of an error, it rejects with:
761 * * a `RemoteServiceError` if there is a problem
762 * with the transaction broadcast service
763 * * `MissingParameterError` if you call the function without a required
764 * parameter
765 * @private
766 */
767 broadcastNameRegistration(
768 preorderTransaction: string,
769 registerTransaction: string,
770 zoneFile: string
771 ) {
772 /*
773 * POST /v1/broadcast/registration
774 * Request body:
775 * JSON.stringify({
776 * preorderTransaction,
777 * registerTransaction,
778 * zoneFile
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 * @ignore
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 * @ignore
819 */
820 countDustOutputs() {
821 throw new Error('Not implemented.')
822 }
823
824 /**
825 * @ignore
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 // aaron: I am *well* aware this is O(n)*O(m) runtime
836 // however, clients should clear the exclude set periodically
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 * This will modify the network's utxo set to include UTXOs
854 * from the given transaction and exclude UTXOs *spent* in
855 * that transaction
856 * @param {String} txHex - the hex-encoded transaction to use
857 * @return {void} no return value, this modifies the UTXO config state
858 * @private
859 * @ignore
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 * @ignore
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 * @ignore
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 * @ignore
941 */
942export 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 * @ignore
955 */
956export 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 // unconfirmed
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 * @ignore
1091 */
1092export class InsightClient extends BitcoinNetwork {
1093 apiUrl: string
1094
1095 constructor(insightUrl: string = 'https://utxo.technofractal.com/') {
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 * @ignore
1147 */
1148export 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* @ignore
1230*/
1231const 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* @ignore
1240*/
1241const MAINNET_DEFAULT = new BlockstackNetwork(
1242 'https://core.blockstack.org',
1243 'https://broadcast.blockstack.org',
1244 new BlockchainInfoApi()
1245)
1246
1247/**
1248 * Get WHOIS-like information for a name, including the address that owns it,
1249 * the block at which it expires, and the zone file anchored to it (if available).
1250 * @param {String} fullyQualifiedName the name to query. Can be on-chain of off-chain.
1251 * @return {Promise} a promise that resolves to the WHOIS-like information
1252 */
1253export function getNameInfo(fullyQualifiedName: string) {
1254 return config.network.getNameInfo(fullyQualifiedName)
1255}
1256
1257/**
1258* @ignore
1259*/
1260export 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