mirror of
https://github.com/alexgo-io/stacks-puppet-node.git
synced 2026-04-15 22:15:09 +08:00
1558 lines
58 KiB
Python
1558 lines
58 KiB
Python
#!/usr/bin/env python2
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Blockstack
|
|
~~~~~
|
|
copyright: (c) 2014-2015 by Halfmoon Labs, Inc.
|
|
copyright: (c) 2016-2018 by Blockstack.org
|
|
|
|
This file is part of Blockstack.
|
|
|
|
Blockstack is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
Blockstack is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
You should have received a copy of the GNU General Public License
|
|
along with Blockstack. If not, see <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import errno
|
|
import time
|
|
import socket
|
|
import posixpath
|
|
import SocketServer
|
|
from SimpleHTTPServer import SimpleHTTPRequestHandler
|
|
import urllib
|
|
import urllib2
|
|
import re
|
|
import base64
|
|
import jsonschema
|
|
import urlparse
|
|
from jsonschema import ValidationError
|
|
import signal
|
|
import json
|
|
from decimal import Decimal
|
|
|
|
import client as blockstackd_client
|
|
from client import get_blockstackd_url
|
|
import scripts as blockstackd_scripts
|
|
from scripts import check_name, check_namespace, check_token_type, check_subdomain, check_block, check_offset, \
|
|
check_count, check_string, check_address
|
|
|
|
import storage
|
|
|
|
from config import BLOCKSTACK_TEST, get_bitcoin_opts, get_blockstack_opts, get_blockstack_api_opts, LENGTHS, VERSION, RPC_MAX_ZONEFILE_LEN
|
|
from client import json_is_error, json_is_exception, decode_name_zonefile, create_bitcoind_service_proxy
|
|
|
|
import virtualchain
|
|
from virtualchain.lib.ecdsalib import get_pubkey_hex, verify_raw_data, ecdsa_private_key
|
|
from virtualchain import AuthServiceProxy, JSONRPCException
|
|
|
|
import blockstack_zones
|
|
|
|
from schemas import OP_BASE64_EMPTY_PATTERN
|
|
|
|
log = virtualchain.get_logger()
|
|
|
|
JSONRPC_MAX_SIZE = 1024 * 1024
|
|
|
|
SATOSHIS_PER_COIN = 10**8
|
|
|
|
def format_unspents(unspents):
|
|
"""
|
|
Used for testing only!
|
|
"""
|
|
assert BLOCKSTACK_TEST, 'format_unspents can only be used in test mode!'
|
|
return [{
|
|
"transaction_hash": s["txid"],
|
|
"outpoint": {
|
|
'hash': s['txid'],
|
|
'index': s["vout"],
|
|
},
|
|
"value": int(Decimal(s["amount"]*SATOSHIS_PER_COIN)),
|
|
"out_script": s["scriptPubKey"],
|
|
"confirmations": s["confirmations"]
|
|
}
|
|
for s in unspents
|
|
]
|
|
|
|
|
|
def get_unspents(address, bitcoind):
|
|
"""
|
|
Used for testing only!
|
|
|
|
Get the spendable transaction outputs, also known as UTXOs or
|
|
unspent transaction outputs.
|
|
|
|
NOTE: this will only return unspents if the address provided is present
|
|
in the bitcoind server.
|
|
"""
|
|
assert BLOCKSTACK_TEST, 'get_unspents can only be used in test mode!'
|
|
|
|
addresses = [address]
|
|
|
|
min_confirmations = 0
|
|
max_confirmation = 2000000000 # just a very large number for max
|
|
unspents = bitcoind.listunspent(min_confirmations, max_confirmation, addresses)
|
|
|
|
if BLOCKSTACK_TEST and len(unspents) == 0:
|
|
try:
|
|
bitcoind.importaddress(str(address))
|
|
unspents = bitcoind.listunspent(min_confirmations, max_confirmation, addresses)
|
|
except Exception as e:
|
|
return format_unspents([])
|
|
|
|
return format_unspents(unspents)
|
|
|
|
|
|
class BlockstackAPIEndpointHandler(SimpleHTTPRequestHandler):
|
|
'''
|
|
Blockstack RESTful API endpoint.
|
|
'''
|
|
|
|
def _send_headers(self, status_code=200, content_type='application/json', more_headers={}):
|
|
"""
|
|
Generate and reply headers
|
|
"""
|
|
self.send_response(status_code)
|
|
self.send_header('content-type', content_type)
|
|
self.send_header('Access-Control-Allow-Origin', '*') # CORS
|
|
for (hdr, val) in more_headers.items():
|
|
self.send_header(hdr, val)
|
|
|
|
self.end_headers()
|
|
|
|
|
|
def _reply_json(self, json_payload, status_code=200):
|
|
"""
|
|
Return a JSON-serializable data structure
|
|
"""
|
|
self._send_headers(status_code=status_code)
|
|
json_str = json.dumps(json_payload)
|
|
self.wfile.write(json_str)
|
|
|
|
|
|
def _read_payload(self, maxlen=None):
|
|
"""
|
|
Read raw uploaded data.
|
|
Return the data on success
|
|
Return None on I/O error, or if maxlen is not None and the number of bytes read is too big
|
|
"""
|
|
|
|
client_address_str = "{}:{}".format(self.client_address[0], self.client_address[1])
|
|
|
|
# check length
|
|
read_len = self.headers.get('content-length', None)
|
|
if read_len is None:
|
|
log.error("No content-length given from {}".format(client_address_str))
|
|
return None
|
|
|
|
try:
|
|
read_len = int(read_len)
|
|
except:
|
|
log.error("Invalid content-length")
|
|
return None
|
|
|
|
if maxlen is not None and read_len >= maxlen:
|
|
log.error("Request from {} is too long ({} >= {})".format(client_address_str, read_len, maxlen))
|
|
return None
|
|
|
|
# get the payload
|
|
request_str = self.rfile.read(read_len)
|
|
return request_str
|
|
|
|
|
|
def _read_json(self, schema=None, maxlen=JSONRPC_MAX_SIZE):
|
|
"""
|
|
Read a JSON payload from the requester
|
|
Return the parsed payload on success
|
|
Return None on error
|
|
"""
|
|
# JSON post?
|
|
request_type = self.headers.get('content-type', None)
|
|
client_address_str = "{}:{}".format(self.client_address[0], self.client_address[1])
|
|
|
|
if request_type != 'application/json':
|
|
log.error("Invalid request of type {} from {}".format(request_type, client_address_str))
|
|
return None
|
|
|
|
request_str = self._read_payload(maxlen=maxlen)
|
|
if request_str is None:
|
|
log.error("Failed to read request")
|
|
return None
|
|
|
|
# parse the payload
|
|
request = None
|
|
try:
|
|
request = json.loads( request_str )
|
|
if schema is not None:
|
|
jsonschema.validate( request, schema )
|
|
|
|
except ValidationError as ve:
|
|
if BLOCKSTACK_DEBUG:
|
|
log.exception(ve)
|
|
log.error("Validation error on request {}...".format(
|
|
request_str[:15]))
|
|
if ve.validator == "maxLength":
|
|
return {"error" : "maxLength"}
|
|
|
|
except (TypeError, ValueError) as ve:
|
|
if BLOCKSTACK_DEBUG:
|
|
log.exception(ve)
|
|
|
|
return None
|
|
|
|
return request
|
|
|
|
|
|
def parse_qs(self, qs):
|
|
"""
|
|
Parse query string, but enforce one instance of each variable.
|
|
Return a dict with the variables on success
|
|
Return None on parse error
|
|
"""
|
|
qs_state = urllib2.urlparse.parse_qs(qs)
|
|
ret = {}
|
|
for qs_var, qs_value_list in qs_state.items():
|
|
if len(qs_value_list) > 1:
|
|
return None
|
|
|
|
ret[qs_var] = qs_value_list[0]
|
|
|
|
return ret
|
|
|
|
|
|
def get_path_and_qs(self):
|
|
"""
|
|
Parse and obtain the path and query values.
|
|
We don't care about fragments.
|
|
|
|
Return {'path': ..., 'qs_values': ...} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
path_parts = self.path.split("?", 1)
|
|
|
|
if len(path_parts) > 1:
|
|
qs = path_parts[1].split("#", 1)[0]
|
|
else:
|
|
qs = ""
|
|
|
|
path = path_parts[0].split("#", 1)[0]
|
|
path = posixpath.normpath(urllib.unquote(path))
|
|
|
|
qs_values = self.parse_qs( qs )
|
|
if qs_values is None:
|
|
return {'error': 'Failed to parse query string'}
|
|
|
|
parts = path.strip('/').split('/')
|
|
|
|
return {'path': path, 'qs_values': qs_values, 'parts': parts}
|
|
|
|
|
|
def _route_match( self, method_name, path_info, route_table ):
|
|
"""
|
|
Look up the method to call
|
|
Return the route info and its arguments on success:
|
|
Return None on error
|
|
"""
|
|
path = path_info['path']
|
|
|
|
for route_path, route_info in route_table.items():
|
|
if method_name not in route_info['routes'].keys():
|
|
continue
|
|
|
|
grps = re.match(route_path, path)
|
|
if grps is None:
|
|
continue
|
|
|
|
groups = grps.groups()
|
|
return {
|
|
'route': route_info,
|
|
'method': route_info['routes'][method_name],
|
|
'args': groups,
|
|
}
|
|
|
|
return None
|
|
|
|
|
|
def OPTIONS_preflight( self, path_info ):
|
|
"""
|
|
Give back CORS preflight check headers
|
|
"""
|
|
self.send_response(200)
|
|
self.send_header('Access-Control-Allow-Origin', '*') # CORS
|
|
self.send_header('Access-Control-Allow-Methods', 'GET, PUT, POST, DELETE')
|
|
self.send_header('Access-Control-Allow-Headers', 'content-type, authorization, range')
|
|
self.send_header('Access-Control-Expose-Headers', 'content-length, content-range')
|
|
self.send_header('Access-Control-Max-Age', 21600)
|
|
self.end_headers()
|
|
return
|
|
|
|
|
|
def GET_names_owned_by_address( self, path_info, blockchain, address ):
|
|
"""
|
|
Get all names owned by an address
|
|
Returns the list on success
|
|
Return 401 on unsupported blockchain
|
|
Return 500 on failure to get names
|
|
"""
|
|
if not check_address(address):
|
|
return self._reply_json({'error': 'Invalid address'}, status_code=400)
|
|
|
|
if blockchain != 'bitcoin':
|
|
return self._reply_json({'error': 'Invalid blockchain'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
address = str(address)
|
|
|
|
subdomain_names = blockstackd_client.get_subdomains_owned_by_address(address, hostport=blockstackd_url)
|
|
if json_is_error(subdomain_names):
|
|
log.error("Failed to fetch subdomains owned by address")
|
|
log.error(subdomain_names)
|
|
subdomain_names = []
|
|
|
|
# make sure we have the right encoding
|
|
new_addr = virtualchain.address_reencode(address)
|
|
if new_addr != address:
|
|
log.debug("Re-encode {} to {}".format(new_addr, address))
|
|
address = new_addr
|
|
|
|
res = blockstackd_client.get_names_owned_by_address(address, hostport=blockstackd_url)
|
|
if json_is_error(res):
|
|
log.error("Failed to get names owned by address")
|
|
self._reply_json({'error': 'Failed to list names by address'}, status_code=res.get('http_status', 500))
|
|
return
|
|
|
|
self._reply_json({'names': res + subdomain_names})
|
|
return
|
|
|
|
|
|
def GET_account_tokens(self, path_info, account_addr):
|
|
"""
|
|
Get all token types that an address owns
|
|
Returns {'tokens': [...]}
|
|
"""
|
|
if not check_address(account_addr):
|
|
return self._reply_json({'error': 'Invalid address'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
res = blockstackd_client.get_account_tokens(account_addr, hostport=blockstackd_url)
|
|
if json_is_error(res):
|
|
log.error("Failed to load tokens for {}: {}".format(account_addr, res['error']))
|
|
return self._reply_json({'error': 'Failed to load tokens for {}: {}'.format(account_addr, res['error'])}, status_code=res.get('http_status', 500))
|
|
|
|
self._reply_json({'tokens': res})
|
|
return
|
|
|
|
|
|
def GET_account_record(self, path_info, account_addr, token_type):
|
|
"""
|
|
Get the state of a particular token account
|
|
Returns the account
|
|
"""
|
|
if not check_address(account_addr):
|
|
return self._reply_json({'error': 'Invalid address'}, status_code=400)
|
|
|
|
if not check_token_type(token_type):
|
|
return self._reply_json({'error': 'Invalid token type'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
res = blockstackd_client.get_account_record(account_addr, token_type, hostport=blockstackd_url)
|
|
if json_is_error(res):
|
|
log.error("Failed to get account state for {} {}: {}".format(account_addr, token_type, res['error']))
|
|
return self._reply_json({'error': 'Failed to get account record for {} {}: {}'.format(token_type, account_addr, res['error'])}, status_code=res.get('http_status', 500))
|
|
|
|
self._reply_json(res)
|
|
return
|
|
|
|
|
|
def GET_account_balance(self, path_info, account_addr, token_type):
|
|
"""
|
|
Get the balance of a particular token
|
|
Returns {'balance': ...}
|
|
"""
|
|
if not check_address(account_addr):
|
|
return self._reply_json({'error': 'Invalid address'}, status_code=400)
|
|
|
|
if not check_token_type(token_type):
|
|
return self._reply_json({'error': 'Invalid token type'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
res = blockstackd_client.get_account_balance(account_addr, token_type, hostport=blockstackd_url)
|
|
if json_is_error(res):
|
|
log.error("Failed to get account balance for {} {}: {}".format(account_addr, token_type, res['error']))
|
|
return self._reply_json({'error': 'Failed to get balance of {} for {}: {}'.format(token_type, account_addr, res['error'])}, status_code=res.get('http_status', 500))
|
|
|
|
self._reply_json({'balance': str(res)}) # NOTE: use a string, since this can be too big for js clients to parse
|
|
return
|
|
|
|
|
|
def GET_account_history(self, path_info, account_addr):
|
|
"""
|
|
Get the history of an account at a given page
|
|
Returns [{...}]
|
|
"""
|
|
if not check_address(account_addr):
|
|
return self._reply_json({'error': 'Invalid address'}, status_code=400)
|
|
|
|
qs_values = path_info['qs_values']
|
|
start_block = qs_values.get('startblock', None)
|
|
end_block = qs_values.get('endblock', None)
|
|
page = qs_values.get('page', None)
|
|
|
|
if start_block is None:
|
|
log.error('startblock= required')
|
|
return self._reply_json({'error': 'startblock= argument required'}, status_code=400)
|
|
|
|
if end_block is None:
|
|
log.error('endblock= required')
|
|
return self_reply_json({'error': 'endblock= argument required'}, status_code=400)
|
|
|
|
if page is None:
|
|
log.error("page= required")
|
|
return self._reply_json({'error': 'page= argument required'}, status_code=400)
|
|
|
|
try:
|
|
start_block = int(start_block)
|
|
end_block = int(end_block)
|
|
page = int(page)
|
|
|
|
assert check_block(start_block)
|
|
assert check_block(end_block)
|
|
assert start_block < end_block
|
|
assert page >= 0
|
|
except:
|
|
return self._reply_json({'error': 'Invalid start block or end block or invalid page'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
res = blockstackd_client.get_account_history_page(account_addr, start_block, end_block, page, hostport=blockstackd_url)
|
|
if json_is_error(res):
|
|
log.error("Failed to list account history for {} at page {} of {}-{}: {}".format(account_addr, page, start_block, end_block, res['error']))
|
|
return self._reply_json({'error': 'Failed to list account history for {} at page {} of {}-{}'.format(account_addr, page, start_block, end_block)}, status_code=res.get('http_status', 500))
|
|
|
|
self._reply_json(res)
|
|
return
|
|
|
|
|
|
def GET_account_at(self, path_info, account_addr, block_height):
|
|
"""
|
|
Get the state(s) of an account at a particular point in history
|
|
Returns [{...}]
|
|
"""
|
|
if not check_address(account_addr):
|
|
return self._reply_json({'error': 'Invalid address'}, status_code=400)
|
|
|
|
try:
|
|
block_height = int(block_height)
|
|
assert check_block(block_height)
|
|
except:
|
|
return self._reply_json({'error': 'Invalid block height'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
res = blockstackd_client.get_account_at(account_addr, block_height, hostport=blockstackd_url)
|
|
if json_is_error(res):
|
|
log.error("Failed to list account history for {} at {}: {}".format(account_addr, block_height, res['error']))
|
|
return self._reply_json({'error': 'Failed to get account state for {} at {}'.format(account_addr, block_height)}, status_code=res.get('http_status', 500))
|
|
|
|
self._reply_json(res)
|
|
return
|
|
|
|
|
|
def GET_names( self, path_info ):
|
|
"""
|
|
Get all names in existence
|
|
If `all=true` is set, then include expired names.
|
|
Returns the list on success
|
|
Returns 401 on invalid arguments
|
|
Returns 500 on failure to get names
|
|
"""
|
|
|
|
include_expired = False
|
|
|
|
qs_values = path_info['qs_values']
|
|
page = qs_values.get('page', None)
|
|
if page is None:
|
|
log.error("Page required")
|
|
return self._reply_json({'error': 'page= argument required'}, status_code=400)
|
|
|
|
try:
|
|
page = int(page)
|
|
if page < 0:
|
|
raise ValueError("Page is negative")
|
|
|
|
except ValueError:
|
|
log.error("Invalid page")
|
|
return self._reply_json({'error': 'Invalid page= value'}, status_code=400)
|
|
|
|
if qs_values.get('all', '').lower() in ['1', 'true']:
|
|
include_expired = True
|
|
|
|
offset = page * 100
|
|
count = 100
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
res = blockstackd_client.get_all_names(offset, count, include_expired=include_expired, hostport=blockstackd_url)
|
|
if json_is_error(res):
|
|
log.error("Failed to list all names (offset={}, count={}): {}".format(offset, count, res['error']))
|
|
self._reply_json({'error': 'Failed to list all names'}, status_code=res.get('http_status', 500))
|
|
return
|
|
|
|
self._reply_json(res)
|
|
return
|
|
|
|
|
|
def GET_name_info( self, path_info, name ):
|
|
"""
|
|
Look up a name's zonefile, address, and last TXID
|
|
Reply status, zonefile, zonefile hash, address, and last TXID.
|
|
'status' can be 'available', 'registered', 'revoked', or 'pending'
|
|
"""
|
|
if not check_name(name) and not check_subdomain(name):
|
|
return self._reply_json({'error': 'Invalid name or subdomain'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
|
|
name_rec = None
|
|
try:
|
|
name_rec = blockstackd_client.get_name_record(name, include_history=False, hostport=blockstackd_url)
|
|
except ValueError:
|
|
return self._reply_json({'error': 'Invalid argument: not a well-formed name or subdomain'}, status_code=400)
|
|
|
|
if 'error' in name_rec:
|
|
if 'not found' in name_rec['error'].lower():
|
|
return self._reply_json({'status': 'available'}, status_code=404)
|
|
elif 'failed to load subdomain' in name_rec['error'].lower():
|
|
return self._reply_json({'status': 'available'}, status_code=404)
|
|
else:
|
|
return self._reply_json({'error': 'Blockstack daemon error: {}'.format(name_rec['error'])}, status_code=name_rec.get('http_status', 500))
|
|
|
|
zonefile_txt = None
|
|
|
|
if 'zonefile' in name_rec:
|
|
zonefile_txt = base64.b64decode(name_rec['zonefile'])
|
|
res = decode_name_zonefile(name, zonefile_txt)
|
|
if res is None:
|
|
log.error("Failed to parse zone file for {}".format(name))
|
|
zonefile_txt = {'error': 'Non-standard zone file'}
|
|
|
|
ret = {}
|
|
|
|
if blockstackd_scripts.is_subdomain(name):
|
|
# subdomain
|
|
log.debug("{} is registered_subdomain".format(name))
|
|
ret = {
|
|
'status': 'registered_subdomain',
|
|
'zonefile': zonefile_txt,
|
|
'zonefile_hash': storage.get_zonefile_data_hash(zonefile_txt),
|
|
'address': name_rec['address'],
|
|
'blockchain': 'bitcoin',
|
|
'last_txid': name_rec['txid']
|
|
}
|
|
|
|
else:
|
|
status = 'revoked' if name_rec['revoked'] else 'registered'
|
|
address = name_rec['address']
|
|
if address:
|
|
address = virtualchain.address_reencode(str(address), network='mainnet')
|
|
|
|
log.debug("{} is {}".format(name, status))
|
|
ret = {
|
|
'status': status,
|
|
'zonefile': zonefile_txt,
|
|
'zonefile_hash': name_rec['value_hash'],
|
|
'address': address,
|
|
'last_txid': name_rec['txid'],
|
|
'blockchain': 'bitcoin',
|
|
'expire_block': name_rec['expire_block'],
|
|
}
|
|
|
|
return self._reply_json(ret)
|
|
|
|
|
|
def GET_name_history(self, path_info, name ):
|
|
"""
|
|
Get the history of a name or subdomain.
|
|
Takes `start_block` and `end_block` in the query string.
|
|
return the history on success
|
|
return 401 on invalid start_block or end_block
|
|
return 500 on failure to query blockstack server
|
|
"""
|
|
if not check_name(name) and not check_subdomain(name):
|
|
return self._reply_json({'error': 'Invalid name or subdomain'}, status_code=400)
|
|
|
|
qs_values = path_info['qs_values']
|
|
start_block = qs_values.get('start_block', None)
|
|
end_block = qs_values.get('end_block', None)
|
|
|
|
try:
|
|
if start_block is None:
|
|
start_block = FIRST_BLOCK_MAINNET
|
|
else:
|
|
start_block = int(start_block)
|
|
|
|
if end_block is None:
|
|
end_block = 2**32 # hope we never get this many blocks!
|
|
else:
|
|
end_block = int(end_block)
|
|
except:
|
|
log.error("Invalid start_block or end_block")
|
|
self._reply_json({'error': 'Invalid start_block or end_block'}, status_code=400)
|
|
return
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
res = blockstackd_client.get_name_record(name, include_expired=True, include_history=True, hostport=blockstackd_url)
|
|
if json_is_error(res):
|
|
return self._reply_json({'error': res['error']}, status_code=res.get('http_status', 500))
|
|
|
|
history = {}
|
|
for block_height in res['history'].keys():
|
|
if int(block_height) >= start_block and int(block_height) <= end_block:
|
|
history[block_height] = res['history'][block_height]
|
|
|
|
return self._reply_json(history)
|
|
|
|
|
|
def GET_name_zonefile( self, path_info, name ):
|
|
"""
|
|
Get the name's current zonefile data.
|
|
With `raw=1` on the query string, return the raw zone file.
|
|
Otherwise, return the (raw) zone file only if it
|
|
is well-formed (and return it in a JSON dict).
|
|
|
|
Reply the {'zonefile': zonefile} on success
|
|
Reply the raw zone file as application/octet-stream of raw=1 is set
|
|
Reply {'error': ...} and HTTP 400 on invalid name or subdomain, or invalid zone file
|
|
Reply {'error': ...} and HTTP 404 if the name doesn't exist
|
|
|
|
Reply 500 on failure to fetch or parse data
|
|
"""
|
|
if not check_name(name) and not check_subdomain(name):
|
|
return self._reply_json({'error': 'Invalid name or subdomain'}, status_code=400)
|
|
|
|
raw = path_info['qs_values'].get('raw', '')
|
|
raw = (raw.lower() in ['1', 'true'])
|
|
|
|
if not blockstackd_scripts.is_name_valid(name) and not blockstackd_scripts.is_subdomain(name):
|
|
return self._reply_json({'error': 'Invalid name or subdomain'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
resp = blockstackd_client.get_name_record(name, include_history=False, hostport=blockstackd_url)
|
|
if json_is_error(resp):
|
|
log.error("Failed to load zone file for {}: {}".format(name, resp['error']))
|
|
return self._reply_json({"error": resp['error']}, status_code=resp.get('http_status', 500))
|
|
|
|
if 'zonefile' not in resp or resp['zonefile'] is None:
|
|
log.error("No zone file for {}".format(name))
|
|
return self._reply_json({'error': 'No zone file for name'}, status_code=404)
|
|
|
|
try:
|
|
zonefile_txt = base64.b64decode(resp['zonefile'])
|
|
except:
|
|
log.error("Zone file data is not serialized properly")
|
|
return self._reply_json({'error': 'Zone file is not serialized properly'}, status_code=400)
|
|
|
|
if raw:
|
|
self._send_headers(status_code=200, content_type='application/octet-stream')
|
|
self.wfile.write(zonefile_txt)
|
|
return
|
|
|
|
else:
|
|
res = decode_name_zonefile(name, zonefile_txt)
|
|
if res is None:
|
|
log.error("Failed to parse zone file for {}".format(name))
|
|
return self._reply_json({'error': 'Non-standard zone file. Try passing raw=1 to get the raw zone file.'})
|
|
|
|
# successfully decodes. Safe to return as a JSON object.
|
|
return self._reply_json({'zonefile': zonefile_txt})
|
|
|
|
|
|
def GET_zonefile(self, path_info, zonefile_hash):
|
|
"""
|
|
Get a raw zonefile
|
|
Reply the zonefile as application/octet-stream
|
|
Reply 404 if not found
|
|
"""
|
|
if not check_string(zonefile_hash, OP_ZONEFILE_HASH_PATTERN):
|
|
return self._reply_json({'error': 'Invalid zone file hash'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
resp = blockstackd_client.get_zonefiles(blockstackd_url, [str(zonefile_hash)])
|
|
if json_is_error(resp):
|
|
log.error("Failed to get {}: {}".format(zonefile_hash, resp['error']))
|
|
return self._reply_json({'error': resp['error']}, status_code=resp.get('http_status', 500))
|
|
|
|
if str(zonefile_hash) not in resp['zonefiles']:
|
|
return self._reply_json({'error': 'Blockstack node does not have this zonefile. Try again later.'}, status_code=404)
|
|
|
|
self._send_headers(status_code=200, content_type='application/octet-stream')
|
|
self.wfile.write(resp['zonefiles'][str(zonefile_hash)])
|
|
return
|
|
|
|
|
|
def POST_zonefile(self, path_info):
|
|
"""
|
|
Publish a zonefile which has *already* been announced.
|
|
Return 200 and {'status': True, 'servers': [...]} on success
|
|
Return 400 on invalid request, such as invalid JSON, JSON that doesn't match the schema, etc.
|
|
Return 500 on failure to replicate the zone file
|
|
"""
|
|
request_schema = {
|
|
'type': 'object',
|
|
'properties': {
|
|
'zonefile': {
|
|
'type': 'string',
|
|
'maxLength': RPC_MAX_ZONEFILE_LEN
|
|
},
|
|
'zonefile_b64': {
|
|
'type': 'string',
|
|
'pattern': OP_BASE64_EMPTY_PATTERN,
|
|
'maxLength': (RPC_MAX_ZONEFILE_LEN * 4) / 3 + 1,
|
|
}
|
|
}
|
|
}
|
|
blockstackd_url = get_blockstackd_url()
|
|
zonefile_json = self._read_json(schema=request_schema)
|
|
if zonefile_json is None:
|
|
return self.reply_json({'error': 'Invalid request'}, status_code=400)
|
|
elif 'error' in zonefile_json:
|
|
log.error("Failed to parse JSON")
|
|
return self._reply_json({'error': 'Invalid request'}, status_code=400)
|
|
|
|
zonefile_str = zonefile_json.get('zonefile', False)
|
|
if zonefile_str:
|
|
# base64-encode
|
|
zonefile_str = base64.b64encode(zonefile_str)
|
|
|
|
else:
|
|
# already given as b64-encoded?
|
|
zonefile_str = zonefile_json.get('zonefile_b64', False)
|
|
if not zonefile_str:
|
|
# neither given
|
|
return self._reply_json({'error': 'Invalid request'}, status_code=400)
|
|
|
|
zonefiles_b64 = [zonefile_str]
|
|
resp = blockstackd_client.put_zonefiles(blockstackd_url, zonefiles_b64)
|
|
if json_is_error(resp):
|
|
log.error("Failed to put {}: {}".format(zonefile_hash, resp['error']))
|
|
return self._reply_json({'error': resp['error']}, status_code=resp.get('http_status', 500))
|
|
|
|
if len(resp['saved']) != 1:
|
|
log.error("Did not save {}, saved is {}".format(zonefile_hash, resp['saved']))
|
|
return self._reply_json({'error': 'Blockstack node did not save the zone file'}, status_code=400)
|
|
|
|
return self._reply_json({'status': True, 'servers': [blockstackd_url]}, status_code=200)
|
|
|
|
|
|
def get_name_zonefile_hashes(self, name):
|
|
"""
|
|
List all zonefile hashes of a name, in historic order.
|
|
Return a list of hashes on success.
|
|
Return {'error': ...} on failure
|
|
"""
|
|
blockstack_hostport = get_blockstackd_url()
|
|
name_rec = blockstackd_client.get_name_record(name, include_history=True, hostport=blockstack_hostport)
|
|
if 'error' in name_rec:
|
|
log.error("Failed to get name record for {}: {}".format(name, name_rec['error']))
|
|
return {'error': 'Failed to get name record for {}: {}'.format(name, name_rec['error'])}
|
|
|
|
name_history = name_rec['history']
|
|
all_update_hashes = []
|
|
|
|
block_ids = name_history.keys()
|
|
block_ids.sort()
|
|
for block_id in block_ids:
|
|
history_items = name_history[block_id]
|
|
for history_item in history_items:
|
|
value_hash = history_item.get('value_hash', None)
|
|
if value_hash is None:
|
|
continue
|
|
|
|
if len(all_update_hashes) > 0 and all_update_hashes[-1] == value_hash:
|
|
continue
|
|
|
|
# changed
|
|
all_update_hashes.append(value_hash)
|
|
|
|
return all_update_hashes
|
|
|
|
|
|
def GET_name_zonefile_by_hash( self, path_info, name, zonefile_hash ):
|
|
"""
|
|
Get a historic zonefile for a name
|
|
With `raw=1` on the query string, return the raw zone file
|
|
|
|
Reply 200 with {'zonefile': zonefile} on success
|
|
Reply 204 with {'error': ...} if the zone file is non-standard
|
|
Reply 404 on not found
|
|
Reply 500 on failure to fetch data
|
|
"""
|
|
if not check_name(name) and not check_subdomain(name):
|
|
return self._reply_json({'error': 'Invalid name or subdomain'}, status_code=400)
|
|
|
|
if not check_string(zonefile_hash, pattern=OP_ZONEFILE_HASH_PATTERN):
|
|
return self._reply_json({'error': 'Invalid zone file hash'}, status_code=400)
|
|
|
|
raw = path_info['qs_values'].get('raw', '')
|
|
raw = (raw.lower() in ['1', 'true'])
|
|
|
|
blockstack_hostport = get_blockstackd_url()
|
|
|
|
historic_zonefiles = self.get_name_zonefile_hashes(name)
|
|
if json_is_error(historic_zonefiles):
|
|
self._reply_json({'error': historic_zonefiles['error']}, status_code=historic_zonefiles.get('http_status', 500))
|
|
return
|
|
|
|
if zonefile_hash not in historic_zonefiles:
|
|
self._reply_json({'error': 'No such zonefile'}, status_code=404)
|
|
return
|
|
|
|
resp = blockstackd_client.get_zonefiles(blockstack_hostport, [str(zonefile_hash)])
|
|
if json_is_error(resp):
|
|
self._reply_json({'error': resp['error']}, status_code=resp.get('http_status', 500))
|
|
return
|
|
|
|
if str(zonefile_hash) not in resp['zonefiles']:
|
|
return self._reply_json({'error': 'Blockstack does not have this zonefile. Try again later.'}, status_code=404)
|
|
|
|
if raw:
|
|
self._send_headers(status_code=200, content_type='application/octet-stream')
|
|
self.wfile.write(resp['zonefiles'][str(zonefile_hash)])
|
|
|
|
else:
|
|
# make sure it's valid
|
|
if str(zonefile_hash) not in resp['zonefiles']:
|
|
log.debug('Failed to find zonefile hash {}, possess {}'.format(
|
|
str(zonefile_hash), resp['zonefiles'].keys()))
|
|
return self._reply_json({'error': 'No such zonefile'}, status_code=404)
|
|
|
|
zonefile_txt = resp['zonefiles'][str(zonefile_hash)]
|
|
res = decode_name_zonefile(name, zonefile_txt)
|
|
if res is None:
|
|
log.error("Failed to parse zone file for {}".format(name))
|
|
self._reply_json({'error': 'Non-standard zone file for {}'.format(name)}, status_code=204)
|
|
return
|
|
|
|
self._reply_json({'zonefile': zonefile_txt})
|
|
|
|
return
|
|
|
|
|
|
def GET_user_profile( self, path_info, user_id ):
|
|
"""
|
|
Get a user profile.
|
|
Reply the profile on success
|
|
Return 404 on failure to load
|
|
"""
|
|
if not check_name(user_id) and not check_subdomain(user_id):
|
|
return self._reply_json({'error': 'Invalid name or subdomain'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
resp = blockstackd_client.resolve_profile(user_id, hostport=blockstackd_url)
|
|
if json_is_error(resp):
|
|
self._reply_json({'error': resp['error']}, status_code=404)
|
|
return
|
|
|
|
self._reply_json(resp['profile'])
|
|
return
|
|
|
|
|
|
def GET_prices_namespace_v1( self, path_info, namespace_id ):
|
|
"""
|
|
Get the price for a namespace (legacy v1 endpoint; only supports satoshis)
|
|
Reply the price for the namespace as {'satoshis': price in satoshis}
|
|
Reply 500 if we can't reach the namespace for whatever reason
|
|
"""
|
|
if not check_namespace(namespace_id):
|
|
return self._reply_json({'error': 'Invalid namespace'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
price_info = blockstackd_client.get_namespace_cost(namespace_id, hostport=blockstackd_url)
|
|
if json_is_error(price_info):
|
|
# error
|
|
status_code = price_info.get('http_status', 500)
|
|
return self._reply_json({'error': price_info['error']}, status_code=status_code)
|
|
|
|
if price_info['units'] != 'BTC':
|
|
# not supported in v1
|
|
return self._reply_json({'error': 'Not priced in BTC. Try /v2/prices/namespace/{}'.format(namespace_id)}, status_code=400)
|
|
|
|
ret = {
|
|
'satoshis': int(price_info['amount'])
|
|
}
|
|
return self._reply_json(ret)
|
|
|
|
|
|
def GET_prices_name_v1( self, path_info, name ):
|
|
"""
|
|
Get the price for a name in a namespace (legacy endpoint for /v1/prices; only supports BTC)
|
|
Reply the price as {'name_price': {'satoshis': price}}
|
|
Reply 404 if the namespace doesn't exist
|
|
Reply 500 if we can't reach the server for whatever reason
|
|
"""
|
|
if not check_name(name):
|
|
return self._reply_json({'error': 'Invalid name'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
price_info = blockstackd_client.get_name_cost(name, hostport=blockstackd_url)
|
|
if json_is_error(price_info):
|
|
# error
|
|
status_code = price_info.get('http_status', 500)
|
|
return self._reply_json({'error': price_info['error']}, status_code=status_code)
|
|
|
|
if price_info['units'] != 'BTC':
|
|
# not supported by this endpoint
|
|
return self._reply_json({'error': 'Not priced in BTC. Try /v2/prices/names/{}'.format(name)}, status_code=400)
|
|
|
|
ret = {
|
|
'satoshis': int(price_info['amount'])
|
|
}
|
|
return self._reply_json({'name_price': ret})
|
|
|
|
|
|
def GET_prices_namespace( self, path_info, namespace_id ):
|
|
"""
|
|
Get the price for a namespace
|
|
Reply the price for the namespace as {'units': "...", 'amount': "..."}
|
|
Reply 500 if we can't reach the namespace for whatever reason
|
|
"""
|
|
if not check_namespace(namespace_id):
|
|
return self._reply_json({'error': 'Invalid namespace'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
price_info = blockstackd_client.get_namespace_cost(namespace_id, hostport=blockstackd_url)
|
|
if json_is_error(price_info):
|
|
# error
|
|
status_code = price_info.get('http_status', 500)
|
|
return self._reply_json({'error': price_info['error']}, status_code=status_code)
|
|
|
|
ret = {
|
|
'amount': str(price_info['amount']), # helps JS clients that can't parse big ints
|
|
'units': price_info['units'],
|
|
}
|
|
if 'satoshis' in price_info:
|
|
ret['satoshis'] = price_info['satoshis']
|
|
|
|
return self._reply_json(ret)
|
|
|
|
|
|
def GET_prices_name( self, path_info, name ):
|
|
"""
|
|
Get the price for a name in a namespace
|
|
Reply the price as {'name_price': {'amount': str(...), 'units': str(...)}} (also, 'satoshis': ... if the name is in BT)
|
|
Reply 404 if the namespace doesn't exist
|
|
Reply 500 if we can't reach the server for whatever reason
|
|
"""
|
|
if not check_name(name):
|
|
return self._reply_json({'error': 'Invalid name'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
price_info = blockstackd_client.get_name_cost(name, hostport=blockstackd_url)
|
|
if json_is_error(price_info):
|
|
# error
|
|
status_code = price_info.get('http_status', 500)
|
|
return self._reply_json({'error': price_info['error']}, status_code=status_code)
|
|
|
|
ret = {
|
|
'amount': str(price_info['amount']), # helps JS clients that can't parse big ints
|
|
'units': price_info['units'],
|
|
}
|
|
if 'satoshis' in price_info:
|
|
ret['satoshis'] = price_info['satoshis']
|
|
|
|
return self._reply_json({'name_price': ret})
|
|
|
|
|
|
def GET_namespaces( self, path_info ):
|
|
"""
|
|
Get the list of all namespaces
|
|
Reply all existing namespaces
|
|
Reply 500 if we can't reach the server for whatever reason
|
|
"""
|
|
qs_values = path_info['qs_values']
|
|
offset = qs_values.get('offset', None)
|
|
count = qs_values.get('count', None)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
namespaces = blockstackd_client.get_all_namespaces(offset=offset, count=count, hostport=blockstackd_url)
|
|
if json_is_error(namespaces):
|
|
# error
|
|
status_code = namespaces.get('http_status', 500)
|
|
return self._reply_json({'error': namespaces['error']}, status_code=status_code)
|
|
|
|
self._reply_json(namespaces)
|
|
return
|
|
|
|
|
|
def GET_namespace_info( self, path_info, namespace_id ):
|
|
"""
|
|
Look up a namespace's info
|
|
Reply information about a namespace
|
|
Reply 404 if the namespace doesn't exist
|
|
Reply 500 for any error in talking to the blocksatck server
|
|
"""
|
|
if not check_namespace(namespace_id):
|
|
return self._reply_json({'error': 'Invalid namespace'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
namespace_rec = blockstackd_client.get_namespace_record(namespace_id, hostport=blockstackd_url)
|
|
if json_is_error(namespace_rec):
|
|
# error
|
|
status_code = namespace_rec.get('http_status', 500)
|
|
return self._reply_json({'error': namespace_rec['error']}, status_code=status_code)
|
|
|
|
self._reply_json(namespace_rec)
|
|
return
|
|
|
|
|
|
def GET_namespace_num_names(self, path_info, namespace_id):
|
|
"""
|
|
Get the number of names in a namespace
|
|
Reply the number on success
|
|
Reply 404 if the namespace does not exist
|
|
Reply 500 on failure to talk to the blockstack server
|
|
"""
|
|
if not check_namespace(namespace_id):
|
|
return self._reply_json({'error': 'Invalid namespace'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
name_count = blockstackd_client.get_num_names_in_namespace(namespace_id, hostport=blockstackd_url)
|
|
if json_is_error(name_count):
|
|
log.error("Failed to load namespace count for {}: {}".format(namespace_id, name_count['error']))
|
|
return self._reply_json({'error': 'Failed to load namespace count: {}'.format(name_count['error'])}, status_code=404)
|
|
|
|
self._reply_json({'names_count': name_count})
|
|
|
|
|
|
def GET_namespace_names( self, path_info, namespace_id ):
|
|
"""
|
|
Get the list of names in a namespace
|
|
Reply the list of names in a namespace
|
|
Reply 404 if the namespace doesn't exist
|
|
Reply 500 for any error in talking to the blockstack server
|
|
"""
|
|
if not check_namespace(namespace_id):
|
|
return self._reply_json({'error': 'Invalid namespace'}, status_code=400)
|
|
|
|
qs_values = path_info['qs_values']
|
|
page = qs_values.get('page', None)
|
|
if page is None:
|
|
log.error("Page required")
|
|
return self._reply_json({'error': 'page= argument required'}, status_code=400)
|
|
|
|
try:
|
|
page = int(page)
|
|
if page < 0:
|
|
raise ValueError()
|
|
|
|
except ValueError:
|
|
log.error("Invalid page")
|
|
return self._reply_json({'error': 'Invalid page= value'}, status_code=400)
|
|
|
|
offset = page * 100
|
|
count = 100
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
namespace_names = blockstackd_client.get_names_in_namespace(namespace_id, offset=offset, count=count, hostport=blockstackd_url)
|
|
if json_is_error(namespace_names):
|
|
# error
|
|
status_code = namespace_names.get('http_status', 500)
|
|
return self._reply_json({'error': namespace_names['error']}, status_code=status_code)
|
|
|
|
self._reply_json(namespace_names)
|
|
return
|
|
|
|
|
|
def GET_blockchain_ops( self, path_info, blockchain_name, blockheight ):
|
|
"""
|
|
Get the name's historic name operations
|
|
Reply the list of nameops at the given block height
|
|
Reply 404 for blockchains other than those supported
|
|
Reply 500 for any error we have in talking to the blockstack server
|
|
"""
|
|
try:
|
|
blockheight = int(blockheight)
|
|
assert check_block(blockheight)
|
|
except:
|
|
return self._reply_json({'error': 'Invalid block'}, status_code=400)
|
|
|
|
if blockchain_name != 'bitcoin':
|
|
# not supported
|
|
return self._reply_json({'error': 'Unsupported blockchain'}, status_code=400)
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
nameops = blockstackd_client.get_blockstack_transactions_at(int(blockheight), hostport=blockstackd_url)
|
|
if json_is_error(nameops):
|
|
# error
|
|
status_code = nameops.get('http_status', 500)
|
|
return self._reply_json({'error': nameops['error']}, status_code=status_code)
|
|
|
|
self._reply_json(nameops)
|
|
return
|
|
|
|
|
|
def GET_blockchain_name_record( self, path_info, blockchain_name, name ):
|
|
"""
|
|
Get the name's blockchain record in full
|
|
Reply the raw blockchain record on success
|
|
Reply 404 if the name is not found
|
|
Reply 500 if we have an error talking to the server
|
|
"""
|
|
if not check_name(name) and not check_subdomain(name):
|
|
return self._reply_json({'error': 'Invalid name or subdomain'}, status_code=400)
|
|
|
|
if blockchain_name != 'bitcoin':
|
|
# not supported
|
|
self._reply_json({'error': 'Unsupported blockchain'}, status_code=400)
|
|
return
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
name_rec = blockstackd_client.get_name_record(name, include_history=False, hostport=blockstackd_url)
|
|
if json_is_error(name_rec):
|
|
# error
|
|
status_code = name_rec.get('http_status', 500)
|
|
return self._reply_json({'error': name_rec['error']}, status_code=status_code)
|
|
|
|
return self._reply_json(name_rec)
|
|
|
|
|
|
def GET_blockchain_num_names( self, path_info, blockchain_name ):
|
|
"""
|
|
Handle GET /blockchains/:blockchainID/name_count
|
|
Takes `all=true` to include expired names
|
|
Reply with the number of names on this blockchain
|
|
"""
|
|
if blockchain_name != 'bitcoin':
|
|
# not supported
|
|
self._reply_json({'error': 'Unsupported blockchain'}, status_code=400)
|
|
return
|
|
|
|
include_expired = False
|
|
|
|
qs_values = path_info['qs_values']
|
|
if qs_values.get('all', '').lower() in ['1', 'true']:
|
|
include_expired = True
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
num_names = blockstackd_client.get_num_names(include_expired=include_expired, hostport=blockstackd_url)
|
|
if json_is_error(num_names):
|
|
# error
|
|
status_code = num_names.get('http_status', 500)
|
|
return self._reply_json({'error': num_names['error']}, status_code=status_code)
|
|
|
|
self._reply_json({'names_count': num_names})
|
|
return
|
|
|
|
|
|
def GET_blockchain_consensus( self, path_info, blockchain_name ):
|
|
"""
|
|
Handle GET /blockchain/:blockchainID/consensus
|
|
Reply the consensus hash at this blockchain's tip
|
|
Reply 401 for unrecognized blockchain
|
|
Reply 404 for blockchains that we don't support
|
|
Reply 500 for any error we have in talking to the blockstack server
|
|
"""
|
|
if blockchain_name != 'bitcoin':
|
|
# not supported
|
|
self._reply_json({'error': 'Unsupported blockchain'}, status_code=400)
|
|
return
|
|
|
|
blockstackd_url = get_blockstackd_url()
|
|
info = blockstackd_client.getinfo(hostport=blockstackd_url)
|
|
if json_is_error(info):
|
|
# error
|
|
status_code = info.get('http_status', 500)
|
|
return self._reply_json({'error': info['error']}, status_code=status_code)
|
|
|
|
self._reply_json({'consensus_hash': info['consensus']})
|
|
return
|
|
|
|
|
|
def _get_balance( self, get_address, min_confs ):
|
|
"""
|
|
Works only in test mode!
|
|
Get the confirmed balance for an address
|
|
"""
|
|
bitcoind_opts = get_bitcoin_opts()
|
|
bitcoind_host = bitcoind_opts['bitcoind_server']
|
|
bitcoind_port = bitcoind_opts['bitcoind_port']
|
|
bitcoind_user = bitcoind_opts['bitcoind_user']
|
|
bitcoind_passwd = bitcoind_opts['bitcoind_passwd']
|
|
|
|
bitcoind = create_bitcoind_service_proxy(bitcoind_user, bitcoind_passwd, server=bitcoind_host, port=bitcoind_port)
|
|
address = virtualchain.address_reencode(get_address)
|
|
|
|
try:
|
|
unspents = get_unspents(address, bitcoind)
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return {'error': 'Failed to get unspents for {}'.format(get_address)}
|
|
|
|
satoshis_confirmed = sum(confirmed_utxo['value'] for confirmed_utxo in
|
|
filter(lambda utxo: utxo['confirmations'] >= min_confs, unspents))
|
|
|
|
return {'balance': satoshis_confirmed}
|
|
|
|
|
|
def GET_confirmed_balance_insight( self, path_info, address ):
|
|
"""
|
|
Works only in test mode!
|
|
Handle GET /insight-api/addr/:address/balance
|
|
"""
|
|
if not BLOCKSTACK_TEST:
|
|
return self._send_headers(status_code=404, content_type='text/plain')
|
|
|
|
if not check_address(address):
|
|
return self._reply_json({'error': 'Invalid address'}, status_code=400)
|
|
|
|
res = self._get_balance(address, 1)
|
|
if 'error' in res:
|
|
return self._reply_json(res, status_code=500)
|
|
|
|
return self._reply_json(res['balance'])
|
|
|
|
|
|
def GET_unconfirmed_balance_insight( self, path_info, address ):
|
|
"""
|
|
Handle GET /insight-api/addr/:address/unconfirmedBalance
|
|
"""
|
|
if not BLOCKSTACK_TEST:
|
|
return self._send_headers(status_code=404, content_type='text/plain')
|
|
|
|
if not check_address(address):
|
|
return self._reply_json({'error': 'Invalid address'}, status_code=400)
|
|
|
|
res = self._get_balance(address, 0)
|
|
if 'error' in res:
|
|
return self._reply_json(res, status_code=500)
|
|
|
|
return self._reply_json(res['balance'])
|
|
|
|
|
|
def GET_utxos_insight( self, path_info, address ):
|
|
"""
|
|
Handle GET /insight-api/addr/:address/utxo
|
|
NOTE: this is not compatible with the Bitcore Insight API method of the same name
|
|
"""
|
|
if not BLOCKSTACK_TEST:
|
|
return self._send_headers(status_code=404, content_type='text/plain')
|
|
|
|
if not check_address(address):
|
|
return self._reply_json({'error': 'Invalid address'}, status_code=400)
|
|
|
|
bitcoind_opts = get_bitcoin_opts()
|
|
bitcoind_host = bitcoind_opts['bitcoind_server']
|
|
bitcoind_port = bitcoind_opts['bitcoind_port']
|
|
bitcoind_user = bitcoind_opts['bitcoind_user']
|
|
bitcoind_passwd = bitcoind_opts['bitcoind_passwd']
|
|
|
|
bitcoind = create_bitcoind_service_proxy(bitcoind_user, bitcoind_passwd, server=bitcoind_host, port=bitcoind_port)
|
|
address = virtualchain.address_reencode(get_address)
|
|
utxos = get_unspents(address, bitcoind)
|
|
return self._reply_json(utxos)
|
|
|
|
|
|
def GET_ping(self, path_info):
|
|
"""
|
|
ping
|
|
"""
|
|
self._reply_json({'status': 'alive', 'version': VERSION})
|
|
return
|
|
|
|
|
|
def _dispatch(self, method_name):
|
|
"""
|
|
Top-level dispatch method
|
|
"""
|
|
URLENCODING_CLASS = r'[a-zA-Z0-9\-_.~%]'
|
|
|
|
routes = {
|
|
r'^/v1/ping$': {
|
|
'routes': {
|
|
'GET': self.GET_ping,
|
|
},
|
|
},
|
|
r'^/v1/addresses/({}{{1,256}})/({}{{1,40}})$'.format(URLENCODING_CLASS, URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_names_owned_by_address,
|
|
},
|
|
},
|
|
r'^/v1/accounts/({}{{1,40}})/tokens$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_account_tokens,
|
|
},
|
|
},
|
|
r'^/v1/accounts/({}{{1,40}})/({}{{1,40}})/status$'.format(URLENCODING_CLASS, URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_account_record,
|
|
},
|
|
},
|
|
r'^/v1/accounts/({}{{1,40}})/({}{{1,40}})/balance$'.format(URLENCODING_CLASS, URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_account_balance,
|
|
},
|
|
},
|
|
r'^/v1/accounts/({}{{1,40}})/history/([0-9]+)$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_account_at,
|
|
},
|
|
},
|
|
r'^/v1/accounts/({}{{1,40}})/history$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_account_history,
|
|
},
|
|
},
|
|
r'^/v1/blockchains/({}{{1,40}})/name_count'.format(URLENCODING_CLASS) : {
|
|
'routes': {
|
|
'GET': self.GET_blockchain_num_names
|
|
},
|
|
},
|
|
r'^/v1/blockchains/({}{{1,40}})/operations/([0-9]+)$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_blockchain_ops
|
|
},
|
|
},
|
|
r'^/v1/blockchains/({}{{1,40}})/names/({}{{1,40}})$'.format(URLENCODING_CLASS, URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_blockchain_name_record,
|
|
},
|
|
},
|
|
r'^/v1/blockchains/({}{{1,40}})/consensus$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_blockchain_consensus,
|
|
},
|
|
},
|
|
r'^/v1/names$': {
|
|
'routes': {
|
|
'GET': self.GET_names,
|
|
},
|
|
},
|
|
r'^/v1/names/({}{{1,256}})$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_name_info,
|
|
},
|
|
},
|
|
r'^/v1/names/({}{{1,256}})/history$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_name_history,
|
|
},
|
|
},
|
|
r'^/v1/names/({}{{1,256}})/zonefile$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_name_zonefile,
|
|
},
|
|
},
|
|
r'^/v1/names/({}{{1,256}})/zonefile/([0-9a-fA-F]{{{}}})$'.format(URLENCODING_CLASS, LENGTHS['value_hash']): {
|
|
'routes': {
|
|
'GET': self.GET_name_zonefile_by_hash, # returns a zonefile
|
|
},
|
|
},
|
|
r'^/v1/namespaces$': {
|
|
'routes': {
|
|
'GET': self.GET_namespaces,
|
|
},
|
|
},
|
|
r'^/v1/namespaces/({}{{1,40}})$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_namespace_info,
|
|
},
|
|
},
|
|
r'^/v1/namespaces/({}{{1,40}})/names$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_namespace_names,
|
|
},
|
|
},
|
|
r'^/v1/namespaces/({}{{1,40}})/name_count$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_namespace_num_names,
|
|
},
|
|
},
|
|
r'^/v1/node/ping$': {
|
|
'routes': {
|
|
'GET': self.GET_ping,
|
|
},
|
|
},
|
|
r'^/v1/prices/namespaces/({}{{1,40}})$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_prices_namespace_v1,
|
|
},
|
|
},
|
|
r'^/v1/prices/names/({}{{1,256}})$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_prices_name_v1,
|
|
},
|
|
},
|
|
r'^/v2/prices/namespaces/({}{{1,40}})$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_prices_namespace,
|
|
},
|
|
},
|
|
r'^/v2/prices/names/({}{{1,256}})$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_prices_name,
|
|
},
|
|
},
|
|
r'^/v1/users/({}{{1,256}})$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_user_profile,
|
|
},
|
|
},
|
|
r'^/insight-api/addr/({}{{1,40}})/balance$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_confirmed_balance_insight,
|
|
},
|
|
},
|
|
r'^/insight-api/addr/({}{{1,40}})/unconfirmedBalance$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_unconfirmed_balance_insight,
|
|
},
|
|
},
|
|
r'^/insight-api/addr/({}{{1,40}})/utxo$'.format(URLENCODING_CLASS): {
|
|
'routes': {
|
|
'GET': self.GET_utxos_insight,
|
|
},
|
|
},
|
|
r'^/v1/zonefiles/([0-9a-fA-F]{{{}}})$'.format(LENGTHS['value_hash']*2): {
|
|
'routes': {
|
|
'GET': self.GET_zonefile,
|
|
},
|
|
},
|
|
r'^/v1/zonefile$': {
|
|
'routes': {
|
|
'POST': self.POST_zonefile,
|
|
},
|
|
},
|
|
r'^/v1/.*$': {
|
|
'routes': {
|
|
'OPTIONS': self.OPTIONS_preflight,
|
|
},
|
|
},
|
|
}
|
|
|
|
conf = get_blockstack_api_opts()
|
|
if not conf['enabled']:
|
|
# this feature is not enabled
|
|
self._send_headers(status_code=404, content_type='text/plain')
|
|
return
|
|
|
|
path_info = self.get_path_and_qs()
|
|
if 'error' in path_info:
|
|
self._send_headers(status_code=400, content_type='text/plain')
|
|
return
|
|
|
|
qs_values = path_info['qs_values']
|
|
|
|
route_info = self._route_match( method_name, path_info, routes )
|
|
if route_info is None:
|
|
log.warning("Unmatched route: {} '{}'".format(method_name, path_info['path']))
|
|
routes = routes.keys()
|
|
routes.sort()
|
|
log.debug(json.dumps(routes, sort_keys=True, indent=4))
|
|
return self._reply_json({'error': 'No such endpoint'}, status_code=404)
|
|
|
|
route_args = route_info['args']
|
|
route_method = route_info['method']
|
|
route = route_info['route']
|
|
|
|
log.debug("\nfull path: {}\nmethod: {}\npath: {}\nqs: {}\nheaders:\n{}\n".format(self.path, method_name, path_info['path'], qs_values, '\n'.join( '{}: {}'.format(k, v) for (k, v) in self.headers.items() )))
|
|
|
|
try:
|
|
return route_method( path_info, *route_args )
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return self._send_headers(status_code=500, content_type='text/plain')
|
|
|
|
|
|
def do_GET(self):
|
|
"""
|
|
Top-level GET dispatch
|
|
"""
|
|
return self._dispatch("GET")
|
|
|
|
def do_POST(self):
|
|
"""
|
|
Top-level POST dispatch
|
|
"""
|
|
return self._dispatch("POST")
|
|
|
|
def do_PUT(self):
|
|
"""
|
|
Top-level PUT dispatch
|
|
"""
|
|
return self._dispatch("PUT")
|
|
|
|
def do_DELETE(self):
|
|
"""
|
|
Top-level DELETE dispatch
|
|
"""
|
|
return self._dispatch("DELETE")
|
|
|
|
def do_HEAD(self):
|
|
"""
|
|
Top-level HEAD dispatch
|
|
"""
|
|
return self._dispatch("HEAD")
|
|
|
|
def do_OPTIONS(self):
|
|
"""
|
|
Top-level OPTIONS dispatch
|
|
"""
|
|
return self._dispatch("OPTIONS")
|
|
|
|
def do_PATCH(self):
|
|
"""
|
|
TOp-level PATCH dispatch
|
|
"""
|
|
return self._dispatch("PATCH")
|
|
|
|
|
|
class BlockstackAPIEndpoint(SocketServer.ThreadingMixIn, SocketServer.TCPServer):
|
|
"""
|
|
Lightweight API endpoint to Blockstack server:
|
|
exposes all of the client methods via a RESTful interface,
|
|
so other local programs (e.g. those that can't use the library)
|
|
can access Blockstack functionality.
|
|
"""
|
|
|
|
def __init__(self, host=None, port=None,
|
|
handler=BlockstackAPIEndpointHandler):
|
|
|
|
"""
|
|
"""
|
|
SocketServer.TCPServer.__init__(self, (host, port), handler, bind_and_activate=False)
|
|
|
|
conf = get_blockstack_api_opts()
|
|
assert conf is not None, 'No API server config given. Add a [blockstack-api] section to your blockstack-server.ini'
|
|
assert conf['enabled'], 'API server is disabled. Set "enabled=True" in the [blockstack-api] section of your blockstack-server.ini'
|
|
|
|
if host is None:
|
|
host = conf['api_host']
|
|
|
|
if port is None:
|
|
port = conf['api_port']
|
|
|
|
self.host = host
|
|
self.port = port
|
|
|
|
|
|
def bind(self):
|
|
"""
|
|
Bind to our port
|
|
"""
|
|
log.debug("Set SO_REUSADDR")
|
|
self.socket.setsockopt( socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 )
|
|
|
|
# we want daemon threads, so we join on abrupt shutdown (applies if multithreaded)
|
|
self.daemon_threads = True
|
|
|
|
self.server_bind()
|
|
self.server_activate()
|
|
|
|
|