UNPKG

aladinnetwork-blockstack

Version:

The Aladin Javascript library for authentication, identity, and storage.

1,268 lines (1,159 loc) 41.6 kB
import { TxOutput, address as bjsAddress, networks, crypto as bjsCrypto, Transaction, payments, Network } from 'bitcoinjs-lib' import FormData from 'form-data' import BN from 'bn.js' import RIPEMD160 from 'ripemd160' import { MissingParameterError, RemoteServiceError } from './errors' import { Logger } from './logger' import { config } from './config' import { fetchPrivate } from './fetchUtil' /** * @ignore */ export type UTXO = { value?: number, confirmations?: number, tx_hash: string, tx_output_n: number } const SATOSHIS_PER_BTC = 1e8 const TX_BROADCAST_SERVICE_ZONE_FILE_ENDPOINT = 'zone-file' const TX_BROADCAST_SERVICE_REGISTRATION_ENDPOINT = 'registration' const TX_BROADCAST_SERVICE_TX_ENDPOINT = 'transaction' /** * @private * @ignore */ export class BitcoinNetwork { broadcastTransaction(transaction: string): Promise<any> { return Promise.reject(new Error(`Not implemented, broadcastTransaction(${transaction})`)) } getBlockHeight(): Promise<number> { return Promise.reject(new Error('Not implemented, getBlockHeight()')) } getTransactionInfo(txid: string): Promise<{block_height: number}> { return Promise.reject(new Error(`Not implemented, getTransactionInfo(${txid})`)) } getNetworkedUTXOs(address: string): Promise<Array<UTXO>> { return Promise.reject(new Error(`Not implemented, getNetworkedUTXOs(${address})`)) } } /** * @private * @ignore */ export class AladinNetwork { aladinAPIUrl: string broadcastServiceUrl: string layer1: Network DUST_MINIMUM: number includeUtxoMap: {[address: string]: UTXO[]} excludeUtxoSet: Array<UTXO> btc: BitcoinNetwork MAGIC_BYTES: string constructor(apiUrl: string, broadcastServiceUrl: string, bitcoinAPI: BitcoinNetwork, network = networks.bitcoin) { this.aladinAPIUrl = apiUrl this.broadcastServiceUrl = broadcastServiceUrl this.layer1 = network this.btc = bitcoinAPI this.DUST_MINIMUM = 5500 this.includeUtxoMap = {} this.excludeUtxoSet = [] this.MAGIC_BYTES = 'id' } coerceAddress(address: string) { const { hash, version } = bjsAddress.fromBase58Check(address) const scriptHashes = [networks.bitcoin.scriptHash, networks.testnet.scriptHash] const pubKeyHashes = [networks.bitcoin.pubKeyHash, networks.testnet.pubKeyHash] let coercedVersion if (scriptHashes.indexOf(version) >= 0) { coercedVersion = this.layer1.scriptHash } else if (pubKeyHashes.indexOf(version) >= 0) { coercedVersion = this.layer1.pubKeyHash } else { throw new Error(`Unrecognized address version number ${version} in ${address}`) } return bjsAddress.toBase58Check(hash, coercedVersion) } /** * @ignore */ getDefaultBurnAddress() { return this.coerceAddress('1111111111111111111114oLvT2') } /** * Get the price of a name via the legacy /v1/prices API endpoint. * @param {String} fullyQualifiedName the name to query * @return {Promise} a promise to an Object with { units: String, amount: BigInteger } * @private */ getNamePriceV1(fullyQualifiedName: string): Promise<{units: string, amount: BN}> { // legacy code path return fetchPrivate(`${this.aladinAPIUrl}/v1/prices/names/${fullyQualifiedName}`) .then((resp) => { if (!resp.ok) { throw new Error(`Failed to query name price for ${fullyQualifiedName}`) } return resp }) .then(resp => resp.json()) .then(resp => resp.name_price) .then((namePrice) => { if (!namePrice || !namePrice.satoshis) { throw new Error( `Failed to get price for ${fullyQualifiedName}. Does the namespace exist?` ) } if (namePrice.satoshis < this.DUST_MINIMUM) { namePrice.satoshis = this.DUST_MINIMUM } const result = { units: 'BTC', amount: new BN(String(namePrice.satoshis)) } return result }) } /** * Get the price of a namespace via the legacy /v1/prices API endpoint. * @param {String} namespaceID the namespace to query * @return {Promise} a promise to an Object with { units: String, amount: BigInteger } * @private */ getNamespacePriceV1(namespaceID: string): Promise<{units: string, amount: BN}> { // legacy code path return fetchPrivate(`${this.aladinAPIUrl}/v1/prices/namespaces/${namespaceID}`) .then((resp) => { if (!resp.ok) { throw new Error(`Failed to query name price for ${namespaceID}`) } return resp }) .then(resp => resp.json()) .then((namespacePrice) => { if (!namespacePrice || !namespacePrice.satoshis) { throw new Error(`Failed to get price for ${namespaceID}`) } if (namespacePrice.satoshis < this.DUST_MINIMUM) { namespacePrice.satoshis = this.DUST_MINIMUM } const result = { units: 'BTC', amount: new BN(String(namespacePrice.satoshis)) } return result }) } /** * Get the price of a name via the /v2/prices API endpoint. * @param {String} fullyQualifiedName the name to query * @return {Promise} a promise to an Object with { units: String, amount: BigInteger } * @private */ getNamePriceV2(fullyQualifiedName: string): Promise<{units: string, amount: BN}> { return fetchPrivate(`${this.aladinAPIUrl}/v2/prices/names/${fullyQualifiedName}`) .then((resp) => { if (resp.status !== 200) { // old core node throw new Error('The upstream node does not handle the /v2/ price namespace') } return resp }) .then(resp => resp.json()) .then(resp => resp.name_price) .then((namePrice) => { if (!namePrice) { throw new Error( `Failed to get price for ${fullyQualifiedName}. Does the namespace exist?` ) } const result = { units: namePrice.units, amount: new BN(namePrice.amount) } if (namePrice.units === 'BTC') { // must be at least dust-minimum const dustMin = new BN(String(this.DUST_MINIMUM)) if (result.amount.ucmp(dustMin) < 0) { result.amount = dustMin } } return result }) } /** * Get the price of a namespace via the /v2/prices API endpoint. * @param {String} namespaceID the namespace to query * @return {Promise} a promise to an Object with { units: String, amount: BigInteger } * @private */ getNamespacePriceV2(namespaceID: string): Promise<{units: string, amount: BN}> { return fetchPrivate(`${this.aladinAPIUrl}/v2/prices/namespaces/${namespaceID}`) .then((resp) => { if (resp.status !== 200) { // old core node throw new Error('The upstream node does not handle the /v2/ price namespace') } return resp }) .then(resp => resp.json()) .then((namespacePrice) => { if (!namespacePrice) { throw new Error(`Failed to get price for ${namespaceID}`) } const result = { units: namespacePrice.units, amount: new BN(namespacePrice.amount) } if (namespacePrice.units === 'BTC') { // must be at least dust-minimum const dustMin = new BN(String(this.DUST_MINIMUM)) if (result.amount.ucmp(dustMin) < 0) { result.amount = dustMin } } return result }) } /** * Get the price of a name. * @param {String} fullyQualifiedName the name to query * @return {Promise} a promise to an Object with { units: String, amount: BigInteger }, where * .units encodes the cryptocurrency units to pay (e.g. BTC, STACKS), and * .amount encodes the number of units, in the smallest denominiated amount * (e.g. if .units is BTC, .amount will be satoshis; if .units is STACKS, * .amount will be microStacks) */ getNamePrice(fullyQualifiedName: string): Promise<{units: string, amount: BN}> { // handle v1 or v2 return Promise.resolve().then(() => this.getNamePriceV2(fullyQualifiedName)) .catch(() => this.getNamePriceV1(fullyQualifiedName)) } /** * Get the price of a namespace * @param {String} namespaceID the namespace to query * @return {Promise} a promise to an Object with { units: String, amount: BigInteger }, where * .units encodes the cryptocurrency units to pay (e.g. BTC, STACKS), and * .amount encodes the number of units, in the smallest denominiated amount * (e.g. if .units is BTC, .amount will be satoshis; if .units is STACKS, * .amount will be microStacks) */ getNamespacePrice(namespaceID: string): Promise<{units: string, amount: BN}> { // handle v1 or v2 return Promise.resolve().then(() => this.getNamespacePriceV2(namespaceID)) .catch(() => this.getNamespacePriceV1(namespaceID)) } /** * How many blocks can pass between a name expiring and the name being able to be * re-registered by a different owner? * @param {string} fullyQualifiedName unused * @return {Promise} a promise to the number of blocks */ getGracePeriod(fullyQualifiedName?: string) { return Promise.resolve(5000) } /** * Get the names -- both on-chain and off-chain -- owned by an address. * @param {String} address the blockchain address (the hash of the owner public key) * @return {Promise} a promise that resolves to a list of names (Strings) */ getNamesOwned(address: string): Promise<string[]> { const networkAddress = this.coerceAddress(address) return fetchPrivate(`${this.aladinAPIUrl}/v1/addresses/bitcoin/${networkAddress}`) .then(resp => resp.json()) .then(obj => obj.names) } /** * Get the blockchain address to which a name's registration fee must be sent * (the address will depend on the namespace in which it is registered.) * @param {String} namespace the namespace ID * @return {Promise} a promise that resolves to an address (String) */ getNamespaceBurnAddress(namespace: string) { return Promise.all([ fetchPrivate(`${this.aladinAPIUrl}/v1/namespaces/${namespace}`), this.getBlockHeight() ]) .then(([resp, blockHeight]) => { if (resp.status === 404) { throw new Error(`No such namespace '${namespace}'`) } else { return Promise.all([resp.json(), blockHeight]) } }) .then(([namespaceInfo, blockHeight]) => { let address = this.getDefaultBurnAddress() if (namespaceInfo.version === 2) { // pay-to-namespace-creator if this namespace is less than 1 year old if (namespaceInfo.reveal_block + 52595 >= blockHeight) { address = namespaceInfo.address } } return address }) .then(address => this.coerceAddress(address)) } /** * Get WHOIS-like information for a name, including the address that owns it, * the block at which it expires, and the zone file anchored to it (if available). * @param {String} fullyQualifiedName the name to query. Can be on-chain of off-chain. * @return {Promise} a promise that resolves to the WHOIS-like information */ getNameInfo(fullyQualifiedName: string) { Logger.debug(this.aladinAPIUrl) const nameLookupURL = `${this.aladinAPIUrl}/v1/names/${fullyQualifiedName}` return fetchPrivate(nameLookupURL) .then((resp) => { if (resp.status === 404) { throw new Error('Name not found') } else if (resp.status !== 200) { throw new Error(`Bad response status: ${resp.status}`) } else { return resp.json() } }) .then((nameInfo) => { Logger.debug(`nameInfo: ${JSON.stringify(nameInfo)}`) // the returned address _should_ be in the correct network --- // aladind gets into trouble because it tries to coerce back to mainnet // and the regtest transaction generation libraries want to use testnet addresses if (nameInfo.address) { return Object.assign({}, nameInfo, { address: this.coerceAddress(nameInfo.address) }) } else { return nameInfo } }) } /** * Get the pricing parameters and creation history of a namespace. * @param {String} namespaceID the namespace to query * @return {Promise} a promise that resolves to the namespace information. */ getNamespaceInfo(namespaceID: string) { return fetchPrivate(`${this.aladinAPIUrl}/v1/namespaces/${namespaceID}`) .then((resp) => { if (resp.status === 404) { throw new Error('Namespace not found') } else if (resp.status !== 200) { throw new Error(`Bad response status: ${resp.status}`) } else { return resp.json() } }) .then((namespaceInfo) => { // the returned address _should_ be in the correct network --- // aladind gets into trouble because it tries to coerce back to mainnet // and the regtest transaction generation libraries want to use testnet addresses if (namespaceInfo.address && namespaceInfo.recipient_address) { return Object.assign({}, namespaceInfo, { address: this.coerceAddress(namespaceInfo.address), recipient_address: this.coerceAddress(namespaceInfo.recipient_address) }) } else { return namespaceInfo } }) } /** * Get a zone file, given its hash. Throws an exception if the zone file * obtained does not match the hash. * @param {String} zonefileHash the ripemd160(sha256) hash of the zone file * @return {Promise} a promise that resolves to the zone file's text */ getZonefile(zonefileHash: string) { return fetchPrivate(`${this.aladinAPIUrl}/v1/zonefiles/${zonefileHash}`) .then((resp) => { if (resp.status === 200) { return resp.text() .then((body) => { const sha256 = bjsCrypto.sha256(Buffer.from(body)) const h = (new RIPEMD160()).update(sha256).digest('hex') if (h !== zonefileHash) { throw new Error(`Zone file contents hash to ${h}, not ${zonefileHash}`) } return body }) } else { throw new Error(`Bad response status: ${resp.status}`) } }) } /** * Get the status of an account for a particular token holding. This includes its total number of * expenditures and credits, lockup times, last txid, and so on. * @param {String} address the account * @param {String} tokenType the token type to query * @return {Promise} a promise that resolves to an object representing the state of the account * for this token */ getAccountStatus(address: string, tokenType: string) { return fetchPrivate(`${this.aladinAPIUrl}/v1/accounts/${address}/${tokenType}/status`) .then((resp) => { if (resp.status === 404) { throw new Error('Account not found') } else if (resp.status !== 200) { throw new Error(`Bad response status: ${resp.status}`) } else { return resp.json() } }).then((accountStatus) => { // coerce all addresses, and convert credit/debit to biginteger const formattedStatus = Object.assign({}, accountStatus, { address: this.coerceAddress(accountStatus.address), debit_value: new BN(String(accountStatus.debit_value)), credit_value: new BN(String(accountStatus.credit_value)) }) return formattedStatus }) } /** * Get a page of an account's transaction history. * @param {String} address the account's address * @param {number} page the page number. Page 0 is the most recent transactions * @return {Promise} a promise that resolves to an Array of Objects, where each Object encodes * states of the account at various block heights (e.g. prior balances, txids, etc) */ getAccountHistoryPage(address: string, page: number): Promise<any[]> { const url = `${this.aladinAPIUrl}/v1/accounts/${address}/history?page=${page}` return fetchPrivate(url) .then((resp) => { if (resp.status === 404) { throw new Error('Account not found') } else if (resp.status !== 200) { throw new Error(`Bad response status: ${resp.status}`) } else { return resp.json() } }) .then((historyList) => { if (historyList.error) { throw new Error(`Unable to get account history page: ${historyList.error}`) } // coerse all addresses and convert to bigint return historyList.map((histEntry: any) => { histEntry.address = this.coerceAddress(histEntry.address) histEntry.debit_value = new BN(String(histEntry.debit_value)) histEntry.credit_value = new BN(String(histEntry.credit_value)) return histEntry }) }) } /** * Get the state(s) of an account at a particular block height. This includes the state of the * account beginning with this block's transactions, as well as all of the states the account * passed through when this block was processed (if any). * @param {String} address the account's address * @param {Integer} blockHeight the block to query * @return {Promise} a promise that resolves to an Array of Objects, where each Object encodes * states of the account at this block. */ getAccountAt(address: string, blockHeight: number): Promise<any[]> { const url = `${this.aladinAPIUrl}/v1/accounts/${address}/history/${blockHeight}` return fetchPrivate(url) .then((resp) => { if (resp.status === 404) { throw new Error('Account not found') } else if (resp.status !== 200) { throw new Error(`Bad response status: ${resp.status}`) } else { return resp.json() } }) .then((historyList) => { if (historyList.error) { throw new Error(`Unable to get historic account state: ${historyList.error}`) } // coerce all addresses return historyList.map((histEntry: any) => { histEntry.address = this.coerceAddress(histEntry.address) histEntry.debit_value = new BN(String(histEntry.debit_value)) histEntry.credit_value = new BN(String(histEntry.credit_value)) return histEntry }) }) } /** * Get the set of token types that this account owns * @param {String} address the account's address * @return {Promise} a promise that resolves to an Array of Strings, where each item encodes the * type of token this account holds (excluding the underlying blockchain's tokens) */ getAccountTokens(address: string): Promise<{tokens: string[]}> { return fetchPrivate(`${this.aladinAPIUrl}/v1/accounts/${address}/tokens`) .then((resp) => { if (resp.status === 404) { throw new Error('Account not found') } else if (resp.status !== 200) { throw new Error(`Bad response status: ${resp.status}`) } else { return resp.json() } }) .then((tokenList) => { if (tokenList.error) { throw new Error(`Unable to get token list: ${tokenList.error}`) } return tokenList }) } /** * Get the number of tokens owned by an account. If the account does not exist or has no * tokens of this type, then 0 will be returned. * @param {String} address the account's address * @param {String} tokenType the type of token to query. * @return {Promise} a promise that resolves to a BigInteger that encodes the number of tokens * held by this account. */ getAccountBalance(address: string, tokenType: string): Promise<BN> { return fetchPrivate(`${this.aladinAPIUrl}/v1/accounts/${address}/${tokenType}/balance`) .then((resp) => { if (resp.status === 404) { // talking to an older aladin core node without the accounts API return Promise.resolve().then(() => new BN('0')) } else if (resp.status !== 200) { throw new Error(`Bad response status: ${resp.status}`) } else { return resp.json() } }) .then((tokenBalance) => { if (tokenBalance.error) { throw new Error(`Unable to get account balance: ${tokenBalance.error}`) } let balance = '0' if (tokenBalance && tokenBalance.balance) { balance = tokenBalance.balance } return new BN(balance) }) } /** * Performs a POST request to the given URL * @param {String} endpoint the name of * @param {String} body [description] * @return {Promise<Object|Error>} Returns a `Promise` that resolves to the object requested. * In the event of an error, it rejects with: * * a `RemoteServiceError` if there is a problem * with the transaction broadcast service * * `MissingParameterError` if you call the function without a required * parameter * * @private */ broadcastServiceFetchHelper(endpoint: string, body: any): Promise<any|Error> { const requestHeaders = { Accept: 'application/json', 'Content-Type': 'application/json' } const options = { method: 'POST', headers: requestHeaders, body: JSON.stringify(body) } const url = `${this.broadcastServiceUrl}/v1/broadcast/${endpoint}` return fetchPrivate(url, options) .then((response) => { if (response.ok) { return response.json() } else { throw new RemoteServiceError(response) } }) } /** * Broadcasts a signed bitcoin transaction to the network optionally waiting to broadcast the * transaction until a second transaction has a certain number of confirmations. * * @param {string} transaction the hex-encoded transaction to broadcast * @param {string} transactionToWatch the hex transaction id of the transaction to watch for * the specified number of confirmations before broadcasting the `transaction` * @param {number} confirmations the number of confirmations `transactionToWatch` must have * before broadcasting `transaction`. * @return {Promise<Object|Error>} Returns a Promise that resolves to an object with a * `transaction_hash` key containing the transaction hash of the broadcasted transaction. * * In the event of an error, it rejects with: * * a `RemoteServiceError` if there is a problem * with the transaction broadcast service * * `MissingParameterError` if you call the function without a required * parameter * @private */ broadcastTransaction( transaction: string, transactionToWatch: string = null, confirmations: number = 6 ) { if (!transaction) { const error = new MissingParameterError('transaction') return Promise.reject(error) } if (!confirmations && confirmations !== 0) { const error = new MissingParameterError('confirmations') return Promise.reject(error) } if (transactionToWatch === null) { return this.btc.broadcastTransaction(transaction) } else { /* * POST /v1/broadcast/transaction * Request body: * JSON.stringify({ * transaction, * transactionToWatch, * confirmations * }) */ const endpoint = TX_BROADCAST_SERVICE_TX_ENDPOINT const requestBody = { transaction, transactionToWatch, confirmations } return this.broadcastServiceFetchHelper(endpoint, requestBody) } } /** * Broadcasts a zone file to the Atlas network via the transaction broadcast service. * * @param {String} zoneFile the zone file to be broadcast to the Atlas network * @param {String} transactionToWatch the hex transaction id of the transaction * to watch for confirmation before broadcasting the zone file to the Atlas network * @return {Promise<Object|Error>} Returns a Promise that resolves to an object with a * `transaction_hash` key containing the transaction hash of the broadcasted transaction. * * In the event of an error, it rejects with: * * a `RemoteServiceError` if there is a problem * with the transaction broadcast service * * `MissingParameterError` if you call the function without a required * parameter * @private */ broadcastZoneFile( zoneFile?: string, transactionToWatch: string = null ) { if (!zoneFile) { return Promise.reject(new MissingParameterError('zoneFile')) } // TODO: validate zonefile if (transactionToWatch) { // broadcast via transaction broadcast service /* * POST /v1/broadcast/zone-file * Request body: * JSON.stringify({ * zoneFile, * transactionToWatch * }) */ const requestBody = { zoneFile, transactionToWatch } const endpoint = TX_BROADCAST_SERVICE_ZONE_FILE_ENDPOINT return this.broadcastServiceFetchHelper(endpoint, requestBody) } else { // broadcast via core endpoint // zone file is two words but core's api treats it as one word 'zonefile' const requestBody = { zonefile: zoneFile } return fetchPrivate(`${this.aladinAPIUrl}/v1/zonefile/`, { method: 'POST', body: JSON.stringify(requestBody), headers: { 'Content-Type': 'application/json' } }) .then((resp) => { const json = resp.json() return json .then((respObj) => { if (respObj.hasOwnProperty('error')) { throw new RemoteServiceError(resp) } return respObj.servers }) }) } } /** * Sends the preorder and registration transactions and zone file * for a Aladin name registration * along with the to the transaction broadcast service. * * The transaction broadcast: * * * immediately broadcasts the preorder transaction * * broadcasts the register transactions after the preorder transaction * has an appropriate number of confirmations * * broadcasts the zone file to the Atlas network after the register transaction * has an appropriate number of confirmations * * @param {String} preorderTransaction the hex-encoded, signed preorder transaction generated * using the `makePreorder` function * @param {String} registerTransaction the hex-encoded, signed register transaction generated * using the `makeRegister` function * @param {String} zoneFile the zone file to be broadcast to the Atlas network * @return {Promise<Object|Error>} Returns a Promise that resolves to an object with a * `transaction_hash` key containing the transaction hash of the broadcasted transaction. * * In the event of an error, it rejects with: * * a `RemoteServiceError` if there is a problem * with the transaction broadcast service * * `MissingParameterError` if you call the function without a required * parameter * @private */ broadcastNameRegistration( preorderTransaction: string, registerTransaction: string, zoneFile: string ) { /* * POST /v1/broadcast/registration * Request body: * JSON.stringify({ * preorderTransaction, * registerTransaction, * zoneFile * }) */ if (!preorderTransaction) { const error = new MissingParameterError('preorderTransaction') return Promise.reject(error) } if (!registerTransaction) { const error = new MissingParameterError('registerTransaction') return Promise.reject(error) } if (!zoneFile) { const error = new MissingParameterError('zoneFile') return Promise.reject(error) } const requestBody = { preorderTransaction, registerTransaction, zoneFile } const endpoint = TX_BROADCAST_SERVICE_REGISTRATION_ENDPOINT return this.broadcastServiceFetchHelper(endpoint, requestBody) } /** * @ignore */ getFeeRate(): Promise<number> { return fetchPrivate('https://bitcoinfees.earn.com/api/v1/fees/recommended') .then(resp => resp.json()) .then(rates => Math.floor(rates.fastestFee)) } /** * @ignore */ countDustOutputs() { throw new Error('Not implemented.') } /** * @ignore */ getUTXOs(address: string): Promise<Array<UTXO>> { return this.getNetworkedUTXOs(address) .then((networkedUTXOs) => { let returnSet = networkedUTXOs.concat() if (this.includeUtxoMap.hasOwnProperty(address)) { returnSet = networkedUTXOs.concat(this.includeUtxoMap[address]) } // aaron: I am *well* aware this is O(n)*O(m) runtime // however, clients should clear the exclude set periodically const excludeSet = this.excludeUtxoSet returnSet = returnSet.filter( (utxo) => { const inExcludeSet = excludeSet.reduce( (inSet, utxoToCheck) => inSet || (utxoToCheck.tx_hash === utxo.tx_hash && utxoToCheck.tx_output_n === utxo.tx_output_n), false ) return !inExcludeSet } ) return returnSet }) } /** * This will modify the network's utxo set to include UTXOs * from the given transaction and exclude UTXOs *spent* in * that transaction * @param {String} txHex - the hex-encoded transaction to use * @return {void} no return value, this modifies the UTXO config state * @private * @ignore */ modifyUTXOSetFrom(txHex: string) { const tx = Transaction.fromHex(txHex) const excludeSet: Array<UTXO> = this.excludeUtxoSet.concat() tx.ins.forEach((utxoUsed) => { const reverseHash = Buffer.from(utxoUsed.hash) reverseHash.reverse() excludeSet.push({ tx_hash: reverseHash.toString('hex'), tx_output_n: utxoUsed.index }) }) this.excludeUtxoSet = excludeSet const txHash = Buffer.from(tx.getHash().reverse()).toString('hex') tx.outs.forEach((utxoCreated, txOutputN) => { const isNullData = function isNullData(script: Buffer) { try { payments.embed({ output: script }, { validate: true }) return true } catch (_) { return false } } if (isNullData(utxoCreated.script)) { return } const address = bjsAddress.fromOutputScript( utxoCreated.script, this.layer1 ) let includeSet: UTXO[] = [] if (this.includeUtxoMap.hasOwnProperty(address)) { includeSet = includeSet.concat(this.includeUtxoMap[address]) } includeSet.push({ tx_hash: txHash, confirmations: 0, value: (utxoCreated as TxOutput).value, tx_output_n: txOutputN }) this.includeUtxoMap[address] = includeSet }) } resetUTXOs(address: string) { delete this.includeUtxoMap[address] this.excludeUtxoSet = [] } /** * @ignore */ getConsensusHash() { return fetchPrivate(`${this.aladinAPIUrl}/v1/blockchains/bitcoin/consensus`) .then(resp => resp.json()) .then(x => x.consensus_hash) } getTransactionInfo(txHash: string): Promise<{block_height: number}> { return this.btc.getTransactionInfo(txHash) } /** * @ignore */ getBlockHeight() { return this.btc.getBlockHeight() } getNetworkedUTXOs(address: string): Promise<Array<UTXO>> { return this.btc.getNetworkedUTXOs(address) } } /** * @ignore */ export class LocalRegtest extends AladinNetwork { constructor(apiUrl: string, broadcastServiceUrl: string, bitcoinAPI: BitcoinNetwork) { super(apiUrl, broadcastServiceUrl, bitcoinAPI, networks.testnet) } getFeeRate(): Promise<number> { return Promise.resolve(Math.floor(0.00001000 * SATOSHIS_PER_BTC)) } } /** * @ignore */ export class BitcoindAPI extends BitcoinNetwork { bitcoindUrl: string bitcoindCredentials: {username: string, password: string} importedBefore: any constructor(bitcoindUrl: string, bitcoindCredentials: {username: string, password: string}) { super() this.bitcoindUrl = bitcoindUrl this.bitcoindCredentials = bitcoindCredentials this.importedBefore = {} } broadcastTransaction(transaction: string) { const jsonRPC = { jsonrpc: '1.0', method: 'sendrawtransaction', params: [transaction] } const authString = Buffer.from(`${this.bitcoindCredentials.username}:${this.bitcoindCredentials.password}`) .toString('base64') const headers = { Authorization: `Basic ${authString}` } return fetchPrivate(this.bitcoindUrl, { method: 'POST', body: JSON.stringify(jsonRPC), headers }) .then(resp => resp.json()) .then(respObj => respObj.result) } getBlockHeight() { const jsonRPC = { jsonrpc: '1.0', method: 'getblockcount' } const authString = Buffer.from(`${this.bitcoindCredentials.username}:${this.bitcoindCredentials.password}`) .toString('base64') const headers = { Authorization: `Basic ${authString}` } return fetchPrivate(this.bitcoindUrl, { method: 'POST', body: JSON.stringify(jsonRPC), headers }) .then(resp => resp.json()) .then(respObj => respObj.result) } getTransactionInfo(txHash: string): Promise<{block_height: number}> { const jsonRPC = { jsonrpc: '1.0', method: 'gettransaction', params: [txHash] } const authString = Buffer.from(`${this.bitcoindCredentials.username}:${this.bitcoindCredentials.password}`) .toString('base64') const headers = { Authorization: `Basic ${authString}` } return fetchPrivate(this.bitcoindUrl, { method: 'POST', body: JSON.stringify(jsonRPC), headers }) .then(resp => resp.json()) .then(respObj => respObj.result) .then(txInfo => txInfo.blockhash) .then((blockhash) => { const jsonRPCBlock = { jsonrpc: '1.0', method: 'getblockheader', params: [blockhash] } headers.Authorization = `Basic ${authString}` return fetchPrivate(this.bitcoindUrl, { method: 'POST', body: JSON.stringify(jsonRPCBlock), headers }) }) .then(resp => resp.json()) .then((respObj) => { if (!respObj || !respObj.result) { // unconfirmed throw new Error('Unconfirmed transaction') } else { return { block_height: respObj.result.height } } }) } getNetworkedUTXOs(address: string): Promise<Array<UTXO>> { const jsonRPCImport = { jsonrpc: '1.0', method: 'importaddress', params: [address] } const jsonRPCUnspent = { jsonrpc: '1.0', method: 'listunspent', params: [0, 9999999, [address]] } const authString = Buffer.from(`${this.bitcoindCredentials.username}:${this.bitcoindCredentials.password}`) .toString('base64') const headers = { Authorization: `Basic ${authString}` } const importPromise = (this.importedBefore[address]) ? Promise.resolve() : fetchPrivate(this.bitcoindUrl, { method: 'POST', body: JSON.stringify(jsonRPCImport), headers }) .then(() => { this.importedBefore[address] = true }) return importPromise .then(() => fetchPrivate(this.bitcoindUrl, { method: 'POST', body: JSON.stringify(jsonRPCUnspent), headers })) .then(resp => resp.json()) .then(x => x.result) .then(utxos => utxos.map( (x: any) => ({ value: Math.round(x.amount * SATOSHIS_PER_BTC), confirmations: x.confirmations, tx_hash: x.txid, tx_output_n: x.vout }) )) } } /** * @ignore */ export class InsightClient extends BitcoinNetwork { apiUrl: string constructor(insightUrl: string = 'https://utxo.technofractal.com/') { super() this.apiUrl = insightUrl } broadcastTransaction(transaction: string) { const jsonData = { rawtx: transaction } return fetchPrivate(`${this.apiUrl}/tx/send`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(jsonData) }) .then(resp => resp.json()) } getBlockHeight() { return fetchPrivate(`${this.apiUrl}/status`) .then(resp => resp.json()) .then(status => status.blocks) } getTransactionInfo(txHash: string): Promise<{block_height: number}> { return fetchPrivate(`${this.apiUrl}/tx/${txHash}`) .then(resp => resp.json()) .then((transactionInfo) => { if (transactionInfo.error) { throw new Error(`Error finding transaction: ${transactionInfo.error}`) } return fetchPrivate(`${this.apiUrl}/block/${transactionInfo.blockHash}`) }) .then(resp => resp.json()) .then(blockInfo => ({ block_height: blockInfo.height })) } getNetworkedUTXOs(address: string): Promise<Array<UTXO>> { return fetchPrivate(`${this.apiUrl}/addr/${address}/utxo`) .then(resp => resp.json()) .then(utxos => utxos.map( (x: any) => ({ value: x.satoshis, confirmations: x.confirmations, tx_hash: x.txid, tx_output_n: x.vout }) )) } } /** * @ignore */ export class BlockchainInfoApi extends BitcoinNetwork { utxoProviderUrl: string constructor(blockchainInfoUrl: string = 'https://blockchain.info') { super() this.utxoProviderUrl = blockchainInfoUrl } getBlockHeight() { return fetchPrivate(`${this.utxoProviderUrl}/latestblock?cors=true`) .then(resp => resp.json()) .then(blockObj => blockObj.height) } getNetworkedUTXOs(address: string): Promise<Array<UTXO>> { return fetchPrivate(`${this.utxoProviderUrl}/unspent?format=json&active=${address}&cors=true`) .then((resp) => { if (resp.status === 500) { Logger.debug('UTXO provider 500 usually means no UTXOs: returning []') return { unspent_outputs: [] } } else { return resp.json() } }) .then(utxoJSON => utxoJSON.unspent_outputs) .then(utxoList => utxoList.map( (utxo: any) => { const utxoOut = { value: utxo.value, tx_output_n: utxo.tx_output_n, confirmations: utxo.confirmations, tx_hash: utxo.tx_hash_big_endian } return utxoOut } )) } getTransactionInfo(txHash: string): Promise<{block_height: number}> { return fetchPrivate(`${this.utxoProviderUrl}/rawtx/${txHash}?cors=true`) .then((resp) => { if (resp.status === 200) { return resp.json() } else { throw new Error(`Could not lookup transaction info for '${txHash}'. Server error.`) } }) .then(respObj => ({ block_height: respObj.block_height })) } broadcastTransaction(transaction: string) { const form = new FormData() form.append('tx', transaction) return fetchPrivate(`${this.utxoProviderUrl}/pushtx?cors=true`, { method: 'POST', body: <any>form }) .then((resp) => { const text = resp.text() return text .then((respText) => { if (respText.toLowerCase().indexOf('transaction submitted') >= 0) { const txHash = Buffer.from( Transaction.fromHex(transaction) .getHash() .reverse()).toString('hex') // big_endian return txHash } else { throw new RemoteServiceError(resp, `Broadcast transaction failed with message: ${respText}`) } }) }) } } /** * @ignore */ const LOCAL_REGTEST = new LocalRegtest( 'http://localhost:16268', 'http://localhost:16269', new BitcoindAPI('http://localhost:18332/', { username: 'aladin', password: 'aladinsystem' }) ) /** * @ignore */ const MAINNET_DEFAULT = new AladinNetwork( 'https://core.blockstack.org', 'https://broadcast.blockstack.org', new BlockchainInfoApi() ) /** * Get WHOIS-like information for a name, including the address that owns it, * the block at which it expires, and the zone file anchored to it (if available). * @param {String} fullyQualifiedName the name to query. Can be on-chain of off-chain. * @return {Promise} a promise that resolves to the WHOIS-like information */ export function getNameInfo(fullyQualifiedName: string) { return config.network.getNameInfo(fullyQualifiedName) } /** * @ignore */ export const network = { AladinNetwork, LocalRegtest, BlockchainInfoApi, BitcoindAPI, InsightClient, defaults: { LOCAL_REGTEST, MAINNET_DEFAULT } }