Neil Booth
8 years ago
commit
a3dbc68614
30 changed files with 2188 additions and 0 deletions
@ -0,0 +1,5 @@ |
|||
*/__pycache__/ |
|||
*/*~ |
|||
*.#* |
|||
*# |
|||
*~ |
@ -0,0 +1,19 @@ |
|||
Thanks to Thomas Voegtlin for creating the Electrum software and |
|||
infrastructure and for maintaining it so diligently. Electrum is the |
|||
probably the best desktop Bitcoin wallet solution for most users. My |
|||
faith in it is such that I use Electrum software to store most of my |
|||
Bitcoins. |
|||
|
|||
Whilst the vast majority of the code here is my own original work and |
|||
includes some new ideas, it is very clear that the general structure |
|||
and concept are those of Electrum. Some parts of the code and ideas |
|||
of Electrum, some of which it itself took from other projects such as |
|||
Abe and pywallet, remain. Thanks to the authors of all the software |
|||
this is derived from. |
|||
|
|||
Thanks to Daniel Bernstein for daemontools and other software, and to |
|||
Matthew Dillon for DragonFlyBSD. They are both deeply inspirational |
|||
people. |
|||
|
|||
And of course, thanks to Satoshi for the wonderful creation that is |
|||
Bitcoin. |
@ -0,0 +1 @@ |
|||
Neil Booth: creator and maintainer |
@ -0,0 +1,267 @@ |
|||
Prerequisites |
|||
============= |
|||
|
|||
ElectrumX should run on any flavour of unix. I have run it |
|||
successfully on MaxOSX and DragonFlyBSD. It won't run out-of-the-box |
|||
on Windows, but the changes required to make it do so should be |
|||
small - patches welcome. |
|||
|
|||
+ Python3 ElectrumX makes heavy use of asyncio so version >=3.5 is required |
|||
+ plyvel Python interface to LevelDB. I am using plyvel-0.9. |
|||
+ aiohttp Python library for asynchronous HTTP. ElectrumX uses it for |
|||
communication with the daemon. I am using aiohttp-0.21. |
|||
|
|||
While not requirements for running ElectrumX, it is intended to be run |
|||
with supervisor software such as Daniel Bernstein's daemontools, or |
|||
Gerald Pape's runit package. These make administration of secure |
|||
unix servers very easy, and I strongly recommend you install one of these |
|||
and familiarise yourself with them. The instructions below and sample |
|||
run scripts assume daemontools; adapting to runit should be trivial |
|||
for someone used to either. |
|||
|
|||
When building the database form the genesis block, ElectrumX has to |
|||
flush large quantities of data to disk and to leveldb. You will have |
|||
a much nicer experience if the database directory is on an SSD than on |
|||
an HDD. Currently to around height 430,000 of the Bitcoin blockchain |
|||
the final size of the leveldb database, and other ElectrumX file |
|||
metadata comes to around 15GB. Leveldb needs a bit more for brief |
|||
periods, and the block chain is only getting longer, so I would |
|||
recommend having at least 30-40GB free space. |
|||
|
|||
|
|||
Running |
|||
======= |
|||
|
|||
Install the prerequisites above. |
|||
|
|||
Check out the code from Github:: |
|||
|
|||
git clone https://github.com/kyuupichan/electrumx.git |
|||
cd electrumx |
|||
|
|||
I have not yet created a setup.py, so for now I suggest you run |
|||
the code from the source tree or a copy of it. |
|||
|
|||
You should create a standard user account to run the server under; |
|||
your own is probably adequate unless paranoid. The paranoid might |
|||
also want to create another user account for the daemontools logging |
|||
process. The sample scripts and these instructions assume it is all |
|||
under one account which I have called 'electrumx'. |
|||
|
|||
Next create a directory where the database will be stored and make it |
|||
writeable by the electrumx account. I recommend this directory live |
|||
on an SSD:: |
|||
|
|||
mkdir /path/to/db_directory |
|||
chown electrumx /path/to/db_directory |
|||
|
|||
Next create a daemontools service directory; this only holds symlinks |
|||
(see daemontools documentation). The 'svscan' program will ensure the |
|||
servers in the directory are running by launching a 'supervise' |
|||
supervisor for the server and another for its logging process. You |
|||
can run 'svscan' under the electrumx account if that is the only one |
|||
involved (server and logger) otherwise it will need to run as root so |
|||
that the user can be switched to electrumx. |
|||
|
|||
Assuming this directory is called service, you would do one of:: |
|||
|
|||
mkdir /service # If running svscan as root |
|||
mkdir ~/service # As electrumx if running svscan as that a/c |
|||
|
|||
Next create a directory to hold the scripts that the 'supervise' |
|||
process spawned by 'svscan' will run - this directory must be readable |
|||
by the 'svscan' process. Suppose this directory is called scripts, you |
|||
might do:: |
|||
|
|||
mkdir -p ~/scripts/electrumx |
|||
|
|||
Then copy the all sample scripts from the ElectrumX source tree there:: |
|||
|
|||
cp -R /path/to/repo/electrumx/samples/scripts ~/scripts/electrumx |
|||
|
|||
This copies 4 things: the top level server run script, a log/ directory |
|||
with the logger run script, an env/ directory, and a NOTES file. |
|||
|
|||
You need to configure the environment variables under env/ to your |
|||
setup, as explained in NOTES. ElectrumX server currently takes no |
|||
command line arguments; all of its configuration is taken from its |
|||
environment which is set up according to env/ directory (see 'envdir' |
|||
man page). Finally you need to change the log/run script to use the |
|||
directory where you want the logs to be written by multilog. The |
|||
directory need not exist as multilog will create it, but its parent |
|||
directory must exist. |
|||
|
|||
Now start the 'svscan' process. This will not do much as the service |
|||
directory is still empty:: |
|||
|
|||
svscan ~/service & disown |
|||
|
|||
svscan is now waiting for services to be added to the directory:: |
|||
|
|||
cd ~/service |
|||
ln -s ~/scripts/electrumx electrumx |
|||
|
|||
Creating the symlink will kick off the server process almost immediately. |
|||
You can see its logs with:: |
|||
|
|||
tail -F /path/to/log/dir/current | tai64nlocal |
|||
|
|||
|
|||
Progress |
|||
======== |
|||
|
|||
Speed indexing the blockchain depends on your hardware of course. As |
|||
Python is single-threaded most of the time only 1 core is kept busy. |
|||
ElectrumX uses Python's asyncio to prefill a cache of future blocks |
|||
asynchronously; this keeps the CPU busy processing the chain and not |
|||
waiting for blocks to be delivered. I therefore doubt there will be |
|||
much boost in performance if the daemon is on the same host: indeed it |
|||
may even be beneficial to have the daemon on a separate machine so the |
|||
machine doing the indexing is focussing on the one task and not the |
|||
wider network. |
|||
|
|||
The FLUSH_SIZE environment variable is an upper bound on how much |
|||
unflushed data is cached before writing to disk + leveldb. The |
|||
default is 4 million items, which is probably fine unless your |
|||
hardware is quite poor. If you've got a really fat machine with lots |
|||
of RAM, 10 million or even higher is likely good (I used 10 million on |
|||
Machine B below without issue so far). A higher number will have |
|||
fewer flushes and save your disk thrashing, but you don't want it so |
|||
high your machine is swapping. If your machine loses power all |
|||
synchronization since the previous flush is lost. |
|||
|
|||
When syncing, ElectrumX is CPU bound over 70% of the time, with the |
|||
rest being bursts of disk activity whilst flushing. Here is my |
|||
experience with the current codebase, to given heights and rough |
|||
wall-time:: |
|||
|
|||
Machine A Machine B DB + Metadata |
|||
100,000 2m 30s 0 (unflushed) |
|||
150,000 35m 4m 30s 0.2 GB |
|||
180,000 1h 5m 9m 0.4 GB |
|||
245,800 3h |
|||
290,000 13h 15m 3.3 GB |
|||
|
|||
Machine A: a low-spec 2011 1.6GHz AMD E-350 dual-core fanless CPU, 8GB |
|||
RAM and a DragonFlyBSD HAMMER fileystem on an SSD. It requests blocks |
|||
over the LAN from a bitcoind on machine B. FLUSH_SIZE: I changed it |
|||
several times between 1 and 5 million during the sync which causes the |
|||
above stats to be a little approximate. Initial FLUSH_SIZE was 1 |
|||
million and first flush at height 126,538. |
|||
|
|||
Machine B: a late 2012 iMac running El-Capitan 10.11.6, 2.9GHz |
|||
quad-core Intel i5 CPU with an HDD and 24GB RAM. Running bitcoind on |
|||
the same machine. FLUSH_SIZE of 10 million. First flush at height |
|||
195,146. |
|||
|
|||
Transactions processed per second seems to gradually decrease over |
|||
time but this statistic is not currently logged and I've not looked |
|||
closely. |
|||
|
|||
For chains other than bitcoin-mainnet sychronization should be much |
|||
faster. |
|||
|
|||
|
|||
Terminating ElectrumX |
|||
===================== |
|||
|
|||
The preferred way to terminate the server process is to send it the |
|||
TERM signal. For a daemontools supervised process this is best done |
|||
by bringing it down like so:: |
|||
|
|||
svc -d ~/service/electrumx |
|||
|
|||
If processing the blockchain the server will start the process of |
|||
flushing to disk. Once that is complete the server will exit. Be |
|||
patient as disk flushing can take a while. |
|||
|
|||
ElectrumX flushes to leveldb using its transaction functionality. The |
|||
plyvel documentation claims this is atomic. I have written ElectrumX |
|||
with the intent that, to the extent this atomicity guarantee holds, |
|||
the database should not get corrupted even if the ElectrumX process if |
|||
forcibly killed or there is loss of power. The worst case is losing |
|||
unflushed in-memory blockchain processing and having to restart from |
|||
the state as of the prior successfully completed flush. |
|||
|
|||
During development I have terminated ElectrumX processes in various |
|||
ways and at random times, and not once have I had any corruption as a |
|||
result of doing so. Mmy only DB corruption has been through buggy |
|||
code. If you do have any database corruption as a result of |
|||
terminating the process without modifying the code I would be very |
|||
interested in hearing details. |
|||
|
|||
I have heard about corruption issues with electrum-server. I cannot |
|||
be sure but with a brief look at the code it does seem that if |
|||
interrupted at the wrong time the databases it uses could become |
|||
inconsistent. |
|||
|
|||
Once the process has terminated, you can start it up again with:: |
|||
|
|||
svc -u ~/service/electrumx |
|||
|
|||
You can see the status of a running service with:: |
|||
|
|||
svstat ~/service/electrumx |
|||
|
|||
Of course, svscan can handle multiple services simultaneously from the |
|||
same service directory, such as a testnet or altcoin server. See the |
|||
man pages of these various commands for more information. |
|||
|
|||
|
|||
|
|||
Understanding the Logs |
|||
====================== |
|||
|
|||
You can see the logs usefully like so:: |
|||
|
|||
tail -F /path/to/log/dir/current | tai64nlocal |
|||
|
|||
Here is typical log output on startup:: |
|||
|
|||
|
|||
2016-10-08 14:46:48.088516500 Launching ElectrumX server... |
|||
2016-10-08 14:46:49.145281500 INFO:root:ElectrumX server starting |
|||
2016-10-08 14:46:49.147215500 INFO:root:switching current directory to /var/nohist/server-test |
|||
2016-10-08 14:46:49.150765500 INFO:DB:using flush size of 1,000,000 entries |
|||
2016-10-08 14:46:49.156489500 INFO:DB:created new database Bitcoin-mainnet |
|||
2016-10-08 14:46:49.157531500 INFO:DB:flushing to levelDB 0 txs and 0 blocks to height -1 tx count: 0 |
|||
2016-10-08 14:46:49.158640500 INFO:DB:flushed. Cache hits: 0/0 writes: 5 deletes: 0 elided: 0 sync: 0d 00h 00m 00s |
|||
2016-10-08 14:46:49.159508500 INFO:RPC:using RPC URL http://user:pass@192.168.0.2:8332/ |
|||
2016-10-08 14:46:49.167352500 INFO:BlockCache:catching up, block cache limit 10MB... |
|||
2016-10-08 14:46:49.318374500 INFO:BlockCache:prefilled 10 blocks to height 10 daemon height: 433,401 block cache size: 2,150 |
|||
2016-10-08 14:46:50.193962500 INFO:BlockCache:prefilled 4,000 blocks to height 4,010 daemon height: 433,401 block cache size: 900,043 |
|||
2016-10-08 14:46:51.253644500 INFO:BlockCache:prefilled 4,000 blocks to height 8,010 daemon height: 433,401 block cache size: 1,600,613 |
|||
2016-10-08 14:46:52.195633500 INFO:BlockCache:prefilled 4,000 blocks to height 12,010 daemon height: 433,401 block cache size: 2,329,325 |
|||
|
|||
Under normal operation these prefill messages repeat fairly regularly. |
|||
Occasionally (depending on how big your FLUSH_SIZE environment |
|||
variable was set, and your hardware, this could be anything from every |
|||
5 minutes to every hour) you will get a flush to disk that begins with: |
|||
|
|||
2016-10-08 06:34:20.841563500 INFO:DB:flushing to levelDB 828,190 txs and 3,067 blocks to height 243,982 tx count: 20,119,669 |
|||
|
|||
During the flush, which can take many minutes, you may see logs like |
|||
this: |
|||
|
|||
2016-10-08 12:20:08.558750500 INFO:DB:address 1dice7W2AicHosf5EL3GFDUVga7TgtPFn hist moving to idx 3000 |
|||
|
|||
These are just informational messages about addresses that have very |
|||
large histories that are generated as those histories are being |
|||
written outt. After the flush has completed a few stats are printed |
|||
about cache hits, the number of writes and deletes, and the number of |
|||
writes that were elided by the cache:: |
|||
|
|||
2016-10-08 06:37:41.035139500 INFO:DB:flushed. Cache hits: 3,185,958/192,336 writes: 781,526 deletes: 465,236 elided: 3,185,958 sync: 0d 06h 57m 03s |
|||
|
|||
After flush-to-disk you may see an aiohttp error; this is the daemon |
|||
timing out the connection while the disk flush was in progress. This |
|||
is harmless; I intend to fix this soon by yielding whilst flushing. |
|||
|
|||
You may see one or two logs about ambiguous UTXOs or hash160s:: |
|||
|
|||
2016-10-08 07:24:34.068609500 INFO:DB:UTXO compressed key collision at height 252943 utxo 115cc1408e5321636675a8fcecd204661a6f27b4b7482b1b7c4402ca4b94b72f / 1 |
|||
|
|||
These are an informational message about artefact of the compression |
|||
scheme ElectrumX uses and are harmless. However, if you see more than |
|||
a handful of these, particularly close together, something is very |
|||
wrong and your DB is probably corrupt. |
@ -0,0 +1,24 @@ |
|||
Copyright (c) 2016, Neil Booth |
|||
|
|||
All rights reserved. |
|||
|
|||
The MIT License (MIT) |
|||
|
|||
Permission is hereby granted, free of charge, to any person obtaining |
|||
a copy of this software and associated documentation files (the |
|||
"Software"), to deal in the Software without restriction, including |
|||
without limitation the rights to use, copy, modify, merge, publish, |
|||
distribute, sublicense, and/or sell copies of the Software, and to |
|||
permit persons to whom the Software is furnished to do so, subject to |
|||
the following conditions: |
|||
|
|||
The above copyright notice and this permission notice shall be |
|||
included in all copies or substantial portions of the Software. |
|||
|
|||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
|||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
|||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
|||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE |
|||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION |
|||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION |
|||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
@ -0,0 +1,126 @@ |
|||
ElectrumX - Reimplementation of Electrum-server |
|||
=============================================== |
|||
:: |
|||
|
|||
Licence: MIT Licence |
|||
Author: Neil Booth |
|||
Language: Python (>=3.5) |
|||
|
|||
|
|||
Motivation |
|||
========== |
|||
|
|||
For privacy and other reasons, I have long wanted to run my own |
|||
Electrum server, but for reasons I cannot remember I struggled to set |
|||
it up or get it to work on my DragonFlyBSD system, and I lost interest |
|||
for over a year. |
|||
|
|||
More recently I heard that Electrum server databases were around 35GB |
|||
in size when gzipped, and had sync times from Genesis of over a week |
|||
(and sufficiently painful that no one seems to have done one for a |
|||
long time) and got curious about improvements. After taking a look at |
|||
the existing server code I decided to try a different approach. |
|||
|
|||
I prefer Python3 over Python2, and the fact that Electrum is stuck on |
|||
Python2 has been frustrating for a while. It's easier to change the |
|||
server to Python3 than the client. |
|||
|
|||
It also seemed like a good way to learn about asyncio, which is a |
|||
wonderful and powerful feature of Python from 3.4 onwards. |
|||
Incidentally asyncio would also make a much better way to implement |
|||
the Electrum client. |
|||
|
|||
Finally though no fan of most altcoins I wanted to write a codebase |
|||
that could easily be reused for those alts that are reasonably |
|||
compatible with Bitcoin. Such an abstraction is also useful for |
|||
testnets, of course. |
|||
|
|||
|
|||
Implementation |
|||
============== |
|||
|
|||
ElectrumX does not currently do any pruning. With luck it may never |
|||
become necessary. So how does it achieve a much more compact database |
|||
than Electrum server, which throws away a lot of information? And |
|||
sync faster to boot? |
|||
|
|||
All of the following likely play a part: |
|||
|
|||
- more compact representation of UTXOs, the mp address index, and |
|||
history. Electrum server stores full transaction hash and height |
|||
for all UTXOs. In its pruned history it does the same. ElectrumX |
|||
just stores the transaction number in the linear history of |
|||
transactions, and it looks like that for at least 5 years that will |
|||
fit in a 4-byte integer. ElectrumX calculates the height from a |
|||
simple lookup in a linear array which is stored on disk. ElectrumX |
|||
also stores transaction hashes in a linear array on disk. |
|||
- storing static append-only metadata which is indexed by position on |
|||
disk rather than in levelDB. It would be nice to do this for histories |
|||
but I cannot think how they could be easily indexable on a filesystem. |
|||
- avoiding unnecessary or redundant computations |
|||
- more efficient memory usage - through more compact data structures and |
|||
and judicious use of memoryviews |
|||
- big caches (controlled via FLUSH_SIZE) |
|||
- asyncio and asynchronous prefetch of blocks. With luck ElectrumX |
|||
will have no need of threads or locking primitives |
|||
- because it prunes electrum-server needs to store undo information, |
|||
ElectrumX should does not need to store undo information for |
|||
blockchain reorganisations (note blockchain reorgs are not yet |
|||
implemented in ElectrumX) |
|||
- finally electrum-server maintains a patricia tree of UTXOs. My |
|||
understanding is this is for future features and not currently |
|||
required. It's unclear precisely how this will be used or what |
|||
could replace or duplicate its functionality in ElectrumX. Since |
|||
ElectrumX stores all necessary blockchain metadata some solution |
|||
should exist. |
|||
|
|||
|
|||
Future/TODO |
|||
=========== |
|||
|
|||
- handling blockchain reorgs |
|||
- handling client connections (heh!) |
|||
- investigating leveldb space / speed tradeoffs |
|||
- seeking out further efficiencies. ElectrumX is CPU bound; it would not |
|||
surprise me if there is a way to cut CPU load by 10-20% more. To squeeze |
|||
more out would probably require some things to move to C or C++. |
|||
|
|||
Once I get round to writing the server part, I will add DoS |
|||
protections if necessary to defend against requests for large |
|||
histories. However with asyncio it would not surprise me if ElectrumX |
|||
could smoothly serve the whole history of the biggest Satoshi dice |
|||
address with minimal negative impact on other connections; we shall |
|||
have to see. If the requestor is running Electrum client I am |
|||
confident that it would collapse under the load far more quickly that |
|||
the server would; it is very inefficeint at handling large wallets |
|||
and histories. |
|||
|
|||
|
|||
Database Format |
|||
=============== |
|||
|
|||
The database and metadata formats of ElectrumX are very likely to |
|||
change in the future. If so old DBs would not be usable. However it |
|||
should be easy to write short Python script to do any necessary |
|||
conversions in-place without having to start afresh. |
|||
|
|||
|
|||
Miscellany |
|||
========== |
|||
|
|||
As I've been researching where the time is going during block chain |
|||
indexing and how various cache sizes and hardware choices affect it, |
|||
I'd appreciate it if anyone trying to synchronize could tell me their:: |
|||
|
|||
- their O/S and filesystem |
|||
- their hardware (CPU name and speed, RAM, and disk kind) |
|||
- whether their daemon was on the same host or not |
|||
- whatever stats about sync height vs time they can provide (the |
|||
logs give it all in wall time) |
|||
- the network they synced |
|||
|
|||
|
|||
Neil Booth |
|||
kyuupichan@gmail.com |
|||
https://github.com/kyuupichan |
|||
1BWwXJH3q6PRsizBkSGm2Uw4Sz1urZ5sCj |
@ -0,0 +1,240 @@ |
|||
# See the file "COPYING" for information about the copyright |
|||
# and warranty status of this software. |
|||
|
|||
|
|||
import inspect |
|||
import sys |
|||
|
|||
from lib.hash import Base58, hash160 |
|||
from lib.script import ScriptPubKey |
|||
from lib.tx import Deserializer |
|||
|
|||
|
|||
class CoinError(Exception): |
|||
pass |
|||
|
|||
|
|||
class Coin(object): |
|||
'''Base class of coin hierarchy''' |
|||
|
|||
# Not sure if these are coin-specific |
|||
HEADER_LEN = 80 |
|||
DEFAULT_RPC_PORT = 8332 |
|||
|
|||
@staticmethod |
|||
def coins(): |
|||
is_coin = lambda obj: (inspect.isclass(obj) |
|||
and issubclass(obj, Coin) |
|||
and obj != Coin) |
|||
pairs = inspect.getmembers(sys.modules[__name__], is_coin) |
|||
# Returned in the order they appear in this file |
|||
return [pair[1] for pair in pairs] |
|||
|
|||
@classmethod |
|||
def lookup_coin_class(cls, name, net): |
|||
for coin in cls.coins(): |
|||
if (coin.NAME.lower() == name.lower() |
|||
and coin.NET.lower() == net.lower()): |
|||
return coin |
|||
raise CoinError('unknown coin {} and network {} combination' |
|||
.format(name, net)) |
|||
|
|||
@staticmethod |
|||
def lookup_xverbytes(verbytes): |
|||
# Order means BTC testnet will override NMC testnet |
|||
for coin in Coin.coins(): |
|||
if verbytes == coin.XPUB_VERBYTES: |
|||
return True, coin |
|||
if verbytes == coin.XPRV_VERBYTES: |
|||
return False, coin |
|||
raise CoinError("version bytes unrecognised") |
|||
|
|||
@classmethod |
|||
def address_to_hash160(cls, addr): |
|||
'''Returns a hash160 given an address''' |
|||
result = Base58.decode_check(addr) |
|||
if len(result) != 21: |
|||
raise CoinError('invalid address: {}'.format(addr)) |
|||
return result[1:] |
|||
|
|||
@classmethod |
|||
def P2PKH_address_from_hash160(cls, hash_bytes): |
|||
'''Returns a P2PKH address given a public key''' |
|||
assert len(hash_bytes) == 20 |
|||
payload = bytes([cls.P2PKH_VERBYTE]) + hash_bytes |
|||
return Base58.encode_check(payload) |
|||
|
|||
@classmethod |
|||
def P2PKH_address_from_pubkey(cls, pubkey): |
|||
'''Returns a coin address given a public key''' |
|||
return cls.P2PKH_address_from_hash160(hash160(pubkey)) |
|||
|
|||
@classmethod |
|||
def P2SH_address_from_hash160(cls, pubkey_bytes): |
|||
'''Returns a coin address given a public key''' |
|||
assert len(hash_bytes) == 20 |
|||
payload = bytes([cls.P2SH_VERBYTE]) + hash_bytes |
|||
return Base58.encode_check(payload) |
|||
|
|||
@classmethod |
|||
def multisig_address(cls, m, pubkeys): |
|||
'''Returns the P2SH address for an M of N multisig transaction. Pass |
|||
the N pubkeys of which M are needed to sign it. If generating |
|||
an address for a wallet, it is the caller's responsibility to |
|||
sort them to ensure order does not matter for, e.g., wallet |
|||
recovery.''' |
|||
script = cls.pay_to_multisig_script(m, pubkeys) |
|||
payload = bytes([cls.P2SH_VERBYTE]) + hash160(pubkey_bytes) |
|||
return Base58.encode_check(payload) |
|||
|
|||
@classmethod |
|||
def pay_to_multisig_script(cls, m, pubkeys): |
|||
'''Returns a P2SH multisig script for an M of N multisig |
|||
transaction.''' |
|||
return ScriptPubKey.multisig_script(m, pubkeys) |
|||
|
|||
@classmethod |
|||
def pay_to_pubkey_script(cls, pubkey): |
|||
'''Returns a pubkey script that pays to pubkey. The input is the |
|||
raw pubkey bytes (length 33 or 65).''' |
|||
return ScriptPubKey.P2PK_script(pubkey) |
|||
|
|||
@classmethod |
|||
def pay_to_address_script(cls, address): |
|||
'''Returns a pubkey script that pays to pubkey hash. Input is the |
|||
address (either P2PKH or P2SH) in base58 form.''' |
|||
raw = Base58.decode_check(address) |
|||
|
|||
# Require version byte plus hash160. |
|||
verbyte = -1 |
|||
if len(raw) == 21: |
|||
verbyte, hash_bytes = raw[0], raw[1:] |
|||
|
|||
if verbyte == cls.P2PKH_VERYBYTE: |
|||
return ScriptPubKey.P2PKH_script(hash_bytes) |
|||
if verbyte == cls.P2SH_VERBYTE: |
|||
return ScriptPubKey.P2SH_script(hash_bytes) |
|||
|
|||
raise CoinError('invalid address: {}'.format(address)) |
|||
|
|||
@classmethod |
|||
def prvkey_WIF(privkey_bytes, compressed): |
|||
"The private key encoded in Wallet Import Format" |
|||
payload = bytearray([cls.WIF_BYTE]) + privkey_bytes |
|||
if compressed: |
|||
payload.append(0x01) |
|||
return Base58.encode_check(payload) |
|||
|
|||
@classmethod |
|||
def read_block(cls, block): |
|||
assert isinstance(block, memoryview) |
|||
d = Deserializer(block[cls.HEADER_LEN:]) |
|||
return d.read_block() |
|||
|
|||
|
|||
class Bitcoin(Coin): |
|||
NAME = "Bitcoin" |
|||
SHORTNAME = "BTC" |
|||
NET = "mainnet" |
|||
XPUB_VERBYTES = bytes.fromhex("0488b21e") |
|||
XPRV_VERBYTES = bytes.fromhex("0488ade4") |
|||
P2PKH_VERBYTE = 0x00 |
|||
P2SH_VERBYTE = 0x05 |
|||
WIF_BYTE = 0x80 |
|||
GENESIS_HASH=(b'000000000019d6689c085ae165831e93' |
|||
b'4ff763ae46a2a6c172b3f1b60a8ce26f') |
|||
|
|||
class BitcoinTestnet(Coin): |
|||
NAME = "Bitcoin" |
|||
SHORTNAME = "XTN" |
|||
NET = "testnet" |
|||
XPUB_VERBYTES = bytes.fromhex("043587cf") |
|||
XPRV_VERBYTES = bytes.fromhex("04358394") |
|||
P2PKH_VERBYTE = 0x6f |
|||
P2SH_VERBYTE = 0xc4 |
|||
WIF_BYTE = 0xef |
|||
|
|||
# Source: pycoin and others |
|||
class Litecoin(Coin): |
|||
NAME = "Litecoin" |
|||
SHORTNAME = "LTC" |
|||
NET = "mainnet" |
|||
XPUB_VERBYTES = bytes.fromhex("019da462") |
|||
XPRV_VERBYTES = bytes.fromhex("019d9cfe") |
|||
P2PKH_VERBYTE = 0x30 |
|||
P2SH_VERBYTE = 0x05 |
|||
WIF_BYTE = 0xb0 |
|||
|
|||
class LitecoinTestnet(Coin): |
|||
NAME = "Litecoin" |
|||
SHORTNAME = "XLT" |
|||
NET = "testnet" |
|||
XPUB_VERBYTES = bytes.fromhex("0436f6e1") |
|||
XPRV_VERBYTES = bytes.fromhex("0436ef7d") |
|||
P2PKH_VERBYTE = 0x6f |
|||
P2SH_VERBYTE = 0xc4 |
|||
WIF_BYTE = 0xef |
|||
|
|||
# Source: namecoin.org |
|||
class Namecoin(Coin): |
|||
NAME = "Namecoin" |
|||
SHORTNAME = "NMC" |
|||
NET = "mainnet" |
|||
XPUB_VERBYTES = bytes.fromhex("d7dd6370") |
|||
XPRV_VERBYTES = bytes.fromhex("d7dc6e31") |
|||
P2PKH_VERBYTE = 0x34 |
|||
P2SH_VERBYTE = 0x0d |
|||
WIF_BYTE = 0xe4 |
|||
|
|||
class NamecoinTestnet(Coin): |
|||
NAME = "Namecoin" |
|||
SHORTNAME = "XNM" |
|||
NET = "testnet" |
|||
XPUB_VERBYTES = bytes.fromhex("043587cf") |
|||
XPRV_VERBYTES = bytes.fromhex("04358394") |
|||
P2PKH_VERBYTE = 0x6f |
|||
P2SH_VERBYTE = 0xc4 |
|||
WIF_BYTE = 0xef |
|||
|
|||
# For DOGE there is disagreement across sites like bip32.org and |
|||
# pycoin. Taken from bip32.org and bitmerchant on github |
|||
class Dogecoin(Coin): |
|||
NAME = "Dogecoin" |
|||
SHORTNAME = "DOGE" |
|||
NET = "mainnet" |
|||
XPUB_VERBYTES = bytes.fromhex("02facafd") |
|||
XPRV_VERBYTES = bytes.fromhex("02fac398") |
|||
P2PKH_VERBYTE = 0x1e |
|||
P2SH_VERBYTE = 0x16 |
|||
WIF_BYTE = 0x9e |
|||
|
|||
class DogecoinTestnet(Coin): |
|||
NAME = "Dogecoin" |
|||
SHORTNAME = "XDT" |
|||
NET = "testnet" |
|||
XPUB_VERBYTES = bytes.fromhex("0432a9a8") |
|||
XPRV_VERBYTES = bytes.fromhex("0432a243") |
|||
P2PKH_VERBYTE = 0x71 |
|||
P2SH_VERBYTE = 0xc4 |
|||
WIF_BYTE = 0xf1 |
|||
|
|||
# Source: pycoin |
|||
class Dash(Coin): |
|||
NAME = "Dash" |
|||
SHORTNAME = "DASH" |
|||
NET = "mainnet" |
|||
XPUB_VERBYTES = bytes.fromhex("02fe52cc") |
|||
XPRV_VERBYTES = bytes.fromhex("02fe52f8") |
|||
P2PKH_VERBYTE = 0x4c |
|||
P2SH_VERBYTE = 0x10 |
|||
WIF_BYTE = 0xcc |
|||
|
|||
class DashTestnet(Coin): |
|||
NAME = "Dogecoin" |
|||
SHORTNAME = "tDASH" |
|||
NET = "testnet" |
|||
XPUB_VERBYTES = bytes.fromhex("3a805837") |
|||
XPRV_VERBYTES = bytes.fromhex("3a8061a0") |
|||
P2PKH_VERBYTE = 0x8b |
|||
P2SH_VERBYTE = 0x13 |
|||
WIF_BYTE = 0xef |
@ -0,0 +1,45 @@ |
|||
# enum-like type |
|||
# From the Python Cookbook from http://code.activestate.com/recipes/67107/ |
|||
|
|||
|
|||
class EnumException(Exception): |
|||
pass |
|||
|
|||
|
|||
class Enumeration: |
|||
|
|||
def __init__(self, name, enumList): |
|||
self.__doc__ = name |
|||
|
|||
lookup = {} |
|||
reverseLookup = {} |
|||
i = 0 |
|||
uniqueNames = set() |
|||
uniqueValues = set() |
|||
for x in enumList: |
|||
if isinstance(x, tuple): |
|||
x, i = x |
|||
if not isinstance(x, str): |
|||
raise EnumException("enum name {} not a string".format(x)) |
|||
if not isinstance(i, int): |
|||
raise EnumException("enum value {} not an integer".format(i)) |
|||
if x in uniqueNames: |
|||
raise EnumException("enum name {} not unique".format(x)) |
|||
if i in uniqueValues: |
|||
raise EnumException("enum value {} not unique".format(x)) |
|||
uniqueNames.add(x) |
|||
uniqueValues.add(i) |
|||
lookup[x] = i |
|||
reverseLookup[i] = x |
|||
i = i + 1 |
|||
self.lookup = lookup |
|||
self.reverseLookup = reverseLookup |
|||
|
|||
def __getattr__(self, attr): |
|||
result = self.lookup.get(attr) |
|||
if result is None: |
|||
raise AttributeError('enumeration has no member {}'.format(attr)) |
|||
return result |
|||
|
|||
def whatis(self, value): |
|||
return self.reverseLookup[value] |
@ -0,0 +1,115 @@ |
|||
# See the file "COPYING" for information about the copyright |
|||
# and warranty status of this software. |
|||
|
|||
import hashlib |
|||
import hmac |
|||
|
|||
from lib.util import bytes_to_int, int_to_bytes |
|||
|
|||
|
|||
def sha256(x): |
|||
assert isinstance(x, (bytes, bytearray, memoryview)) |
|||
return hashlib.sha256(x).digest() |
|||
|
|||
|
|||
def ripemd160(x): |
|||
assert isinstance(x, (bytes, bytearray, memoryview)) |
|||
h = hashlib.new('ripemd160') |
|||
h.update(x) |
|||
return h.digest() |
|||
|
|||
|
|||
def double_sha256(x): |
|||
return sha256(sha256(x)) |
|||
|
|||
|
|||
def hmac_sha512(key, msg): |
|||
return hmac.new(key, msg, hashlib.sha512).digest() |
|||
|
|||
|
|||
def hash160(x): |
|||
return ripemd160(sha256(x)) |
|||
|
|||
|
|||
class InvalidBase58String(Exception): |
|||
pass |
|||
|
|||
|
|||
class InvalidBase58CheckSum(Exception): |
|||
pass |
|||
|
|||
|
|||
class Base58(object): |
|||
|
|||
chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' |
|||
assert len(chars) == 58 |
|||
cmap = {c: n for n, c in enumerate(chars)} |
|||
|
|||
@staticmethod |
|||
def char_value(c): |
|||
val = Base58.cmap.get(c) |
|||
if val is None: |
|||
raise InvalidBase58String |
|||
return val |
|||
|
|||
@staticmethod |
|||
def decode(txt): |
|||
"""Decodes txt into a big-endian bytearray.""" |
|||
if not isinstance(txt, str): |
|||
raise InvalidBase58String("a string is required") |
|||
|
|||
if not txt: |
|||
raise InvalidBase58String("string cannot be empty") |
|||
|
|||
value = 0 |
|||
for c in txt: |
|||
value = value * 58 + Base58.char_value(c) |
|||
|
|||
result = int_to_bytes(value) |
|||
|
|||
# Prepend leading zero bytes if necessary |
|||
count = 0 |
|||
for c in txt: |
|||
if c != '1': |
|||
break |
|||
count += 1 |
|||
if count: |
|||
result = bytes(count) + result |
|||
|
|||
return result |
|||
|
|||
@staticmethod |
|||
def encode(be_bytes): |
|||
"""Converts a big-endian bytearray into a base58 string.""" |
|||
value = bytes_to_int(be_bytes) |
|||
|
|||
txt = '' |
|||
while value: |
|||
value, mod = divmod(value, 58) |
|||
txt += Base58.chars[mod] |
|||
|
|||
for byte in be_bytes: |
|||
if byte != 0: |
|||
break |
|||
txt += '1' |
|||
|
|||
return txt[::-1] |
|||
|
|||
@staticmethod |
|||
def decode_check(txt): |
|||
'''Decodes a Base58Check-encoded string to a payload. The version |
|||
prefixes it.''' |
|||
be_bytes = Base58.decode(txt) |
|||
result, check = be_bytes[:-4], be_bytes[-4:] |
|||
if check != double_sha256(result)[:4]: |
|||
raise InvalidBase58CheckSum |
|||
return result |
|||
|
|||
@staticmethod |
|||
def encode_check(payload): |
|||
"""Encodes a payload bytearray (which includes the version byte(s)) |
|||
into a Base58Check string.""" |
|||
assert isinstance(payload, (bytes, bytearray)) |
|||
|
|||
be_bytes = payload + double_sha256(payload)[:4] |
|||
return Base58.encode(be_bytes) |
@ -0,0 +1,306 @@ |
|||
# See the file "COPYING" for information about the copyright |
|||
# and warranty status of this software. |
|||
|
|||
from binascii import hexlify |
|||
import struct |
|||
|
|||
from lib.enum import Enumeration |
|||
from lib.hash import hash160 |
|||
from lib.util import cachedproperty |
|||
|
|||
|
|||
class ScriptError(Exception): |
|||
pass |
|||
|
|||
|
|||
OpCodes = Enumeration("Opcodes", [ |
|||
("OP_0", 0), ("OP_PUSHDATA1", 76), |
|||
"OP_PUSHDATA2", "OP_PUSHDATA4", "OP_1NEGATE", |
|||
"OP_RESERVED", |
|||
"OP_1", "OP_2", "OP_3", "OP_4", "OP_5", "OP_6", "OP_7", "OP_8", |
|||
"OP_9", "OP_10", "OP_11", "OP_12", "OP_13", "OP_14", "OP_15", "OP_16", |
|||
"OP_NOP", "OP_VER", "OP_IF", "OP_NOTIF", "OP_VERIF", "OP_VERNOTIF", |
|||
"OP_ELSE", "OP_ENDIF", "OP_VERIFY", "OP_RETURN", |
|||
"OP_TOALTSTACK", "OP_FROMALTSTACK", "OP_2DROP", "OP_2DUP", "OP_3DUP", |
|||
"OP_2OVER", "OP_2ROT", "OP_2SWAP", "OP_IFDUP", "OP_DEPTH", "OP_DROP", |
|||
"OP_DUP", "OP_NIP", "OP_OVER", "OP_PICK", "OP_ROLL", "OP_ROT", |
|||
"OP_SWAP", "OP_TUCK", |
|||
"OP_CAT", "OP_SUBSTR", "OP_LEFT", "OP_RIGHT", "OP_SIZE", |
|||
"OP_INVERT", "OP_AND", "OP_OR", "OP_XOR", "OP_EQUAL", "OP_EQUALVERIFY", |
|||
"OP_RESERVED1", "OP_RESERVED2", |
|||
"OP_1ADD", "OP_1SUB", "OP_2MUL", "OP_2DIV", "OP_NEGATE", "OP_ABS", |
|||
"OP_NOT", "OP_0NOTEQUAL", "OP_ADD", "OP_SUB", "OP_MUL", "OP_DIV", "OP_MOD", |
|||
"OP_LSHIFT", "OP_RSHIFT", "OP_BOOLAND", "OP_BOOLOR", "OP_NUMEQUAL", |
|||
"OP_NUMEQUALVERIFY", "OP_NUMNOTEQUAL", "OP_LESSTHAN", "OP_GREATERTHAN", |
|||
"OP_LESSTHANOREQUAL", "OP_GREATERTHANOREQUAL", "OP_MIN", "OP_MAX", |
|||
"OP_WITHIN", |
|||
"OP_RIPEMD160", "OP_SHA1", "OP_SHA256", "OP_HASH160", "OP_HASH256", |
|||
"OP_CODESEPARATOR", "OP_CHECKSIG", "OP_CHECKSIGVERIFY", "OP_CHECKMULTISIG", |
|||
"OP_CHECKMULTISIGVERIFY", |
|||
"OP_NOP1", |
|||
"OP_CHECKLOCKTIMEVERIFY", "OP_CHECKSEQUENCEVERIFY" |
|||
]) |
|||
|
|||
|
|||
# Paranoia to make it hard to create bad scripts |
|||
assert OpCodes.OP_DUP == 0x76 |
|||
assert OpCodes.OP_HASH160 == 0xa9 |
|||
assert OpCodes.OP_EQUAL == 0x87 |
|||
assert OpCodes.OP_EQUALVERIFY == 0x88 |
|||
assert OpCodes.OP_CHECKSIG == 0xac |
|||
assert OpCodes.OP_CHECKMULTISIG == 0xae |
|||
|
|||
|
|||
class ScriptSig(object): |
|||
'''A script from a tx input, typically provides one or more signatures.''' |
|||
|
|||
SIG_ADDRESS, SIG_MULTI, SIG_PUBKEY, SIG_UNKNOWN = range(4) |
|||
|
|||
def __init__(self, script, coin, kind, sigs, pubkeys): |
|||
self.script = script |
|||
self.coin = coin |
|||
self.kind = kind |
|||
self.sigs = sigs |
|||
self.pubkeys = pubkeys |
|||
|
|||
@cachedproperty |
|||
def address(self): |
|||
if self.kind == SIG_ADDRESS: |
|||
return self.coin.address_from_pubkey(self.pubkeys[0]) |
|||
if self.kind == SIG_MULTI: |
|||
return self.coin.multsig_address(self.pubkeys) |
|||
return 'Unknown' |
|||
|
|||
@classmethod |
|||
def from_script(cls, script, coin): |
|||
'''Returns an instance of this class. Uncrecognised scripts return |
|||
an object of kind SIG_UNKNOWN.''' |
|||
try: |
|||
return cls.parse_script(script, coin) |
|||
except ScriptError: |
|||
return cls(script, coin, SIG_UNKNOWN, [], []) |
|||
|
|||
@classmethod |
|||
def parse_script(cls, script, coin): |
|||
'''Returns an instance of this class. Raises on unrecognised |
|||
scripts.''' |
|||
ops, datas = Script.get_ops(script) |
|||
|
|||
# Address, PubKey and P2SH redeems only push data |
|||
if not ops or not Script.match_ops(ops, [-1] * len(ops)): |
|||
raise ScriptError('unknown scriptsig pattern') |
|||
|
|||
# Assume double data pushes are address redeems, single data |
|||
# pushes are pubkey redeems |
|||
if len(ops) == 2: # Signature, pubkey |
|||
return cls(script, coin, SIG_ADDRESS, [datas[0]], [datas[1]]) |
|||
|
|||
if len(ops) == 1: # Pubkey |
|||
return cls(script, coin, SIG_PUBKEY, [datas[0]], []) |
|||
|
|||
# Presumably it is P2SH (though conceivably the above could be |
|||
# too; cannot be sure without the send-to script). We only |
|||
# handle CHECKMULTISIG P2SH, which because of a bitcoin core |
|||
# bug always start with an unused OP_0. |
|||
if ops[0] != OpCodes.OP_0: |
|||
raise ScriptError('unknown scriptsig pattern; expected OP_0') |
|||
|
|||
# OP_0, Sig1, ..., SigM, pk_script |
|||
m = len(ops) - 2 |
|||
pk_script = datas[-1] |
|||
pk_ops, pk_datas = Script.get_ops(script) |
|||
|
|||
# OP_2 pubkey1 pubkey2 pubkey3 OP_3 OP_CHECKMULTISIG |
|||
n = len(pk_ops) - 3 |
|||
pattern = ([OpCodes.OP_1 + m - 1] + [-1] * n |
|||
+ [OpCodes.OP_1 + n - 1, OpCodes.OP_CHECKMULTISIG]) |
|||
|
|||
if m <= n and Script.match_ops(pk_ops, pattern): |
|||
return cls(script, coin, SIG_MULTI, datas[1:-1], pk_datas[1:-2]) |
|||
|
|||
raise ScriptError('unknown multisig P2SH pattern') |
|||
|
|||
|
|||
class ScriptPubKey(object): |
|||
'''A script from a tx output that gives conditions necessary for |
|||
spending.''' |
|||
|
|||
TO_ADDRESS, TO_P2SH, TO_PUBKEY, TO_UNKNOWN = range(4) |
|||
TO_ADDRESS_OPS = [OpCodes.OP_DUP, OpCodes.OP_HASH160, -1, |
|||
OpCodes.OP_EQUALVERIFY, OpCodes.OP_CHECKSIG] |
|||
TO_P2SH_OPS = [OpCodes.OP_HASH160, -1, OpCodes.OP_EQUAL] |
|||
TO_PUBKEY_OPS = [-1, OpCodes.OP_CHECKSIG] |
|||
|
|||
def __init__(self, script, coin, kind, hash160, pubkey=None): |
|||
self.script = script |
|||
self.coin = coin |
|||
self.kind = kind |
|||
self.hash160 = hash160 |
|||
if pubkey: |
|||
self.pubkey = pubkey |
|||
|
|||
@cachedproperty |
|||
def address(self): |
|||
if self.kind == ScriptPubKey.TO_P2SH: |
|||
return self.coin.P2SH_address_from_hash160(self.hash160) |
|||
if self.hash160: |
|||
return self.coin.P2PKH_address_from_hash160(self.hash160) |
|||
return '' |
|||
|
|||
@classmethod |
|||
def from_script(cls, script, coin): |
|||
'''Returns an instance of this class. Uncrecognised scripts return |
|||
an object of kind TO_UNKNOWN.''' |
|||
try: |
|||
return cls.parse_script(script, coin) |
|||
except ScriptError: |
|||
return cls(script, coin, cls.TO_UNKNOWN, None) |
|||
|
|||
@classmethod |
|||
def parse_script(cls, script, coin): |
|||
'''Returns an instance of this class. Raises on unrecognised |
|||
scripts.''' |
|||
ops, datas = Script.get_ops(script) |
|||
|
|||
if Script.match_ops(ops, cls.TO_ADDRESS_OPS): |
|||
return cls(script, coin, cls.TO_ADDRESS, datas[2]) |
|||
|
|||
if Script.match_ops(ops, cls.TO_P2SH_OPS): |
|||
return cls(script, coin, cls.TO_P2SH, datas[1]) |
|||
|
|||
if Script.match_ops(ops, cls.TO_PUBKEY_OPS): |
|||
pubkey = datas[0] |
|||
return cls(script, coin, cls.TO_PUBKEY, hash160(pubkey), pubkey) |
|||
|
|||
raise ScriptError('unknown script pubkey pattern') |
|||
|
|||
@classmethod |
|||
def P2SH_script(cls, hash160): |
|||
return (bytes([OpCodes.OP_HASH160]) |
|||
+ Script.push_data(hash160) |
|||
+ bytes([OpCodes.OP_EQUAL])) |
|||
|
|||
@classmethod |
|||
def P2PKH_script(cls, hash160): |
|||
return (bytes([OpCodes.OP_DUP, OpCodes.OP_HASH160]) |
|||
+ Script.push_data(hash160) |
|||
+ bytes([OpCodes.OP_EQUALVERIFY, OpCodes.OP_CHECKSIG])) |
|||
|
|||
@classmethod |
|||
def validate_pubkey(cls, pubkey, req_compressed=False): |
|||
if isinstance(pubkey, (bytes, bytearray)): |
|||
if len(pubkey) == 33 and pubkey[0] in (2, 3): |
|||
return # Compressed |
|||
if len(pubkey) == 65 and pubkey[0] == 4: |
|||
if not req_compressed: |
|||
return |
|||
raise PubKeyError('uncompressed pubkeys are invalid') |
|||
raise PubKeyError('invalid pubkey {}'.format(pubkey)) |
|||
|
|||
@classmethod |
|||
def pubkey_script(cls, pubkey): |
|||
cls.validate_pubkey(pubkey) |
|||
return Script.push_data(pubkey) + bytes([OpCodes.OP_CHECKSIG]) |
|||
|
|||
@classmethod |
|||
def multisig_script(cls, m, pubkeys): |
|||
'''Returns the script for a pay-to-multisig transaction.''' |
|||
n = len(pubkeys) |
|||
if not 1 <= m <= n <= 15: |
|||
raise ScriptError('{:d} of {:d} multisig script not possible' |
|||
.format(m, n)) |
|||
for pubkey in pubkeys: |
|||
cls.validate_pubkey(pubkey, req_compressed=True) |
|||
# See https://bitcoin.org/en/developer-guide |
|||
# 2 of 3 is: OP_2 pubkey1 pubkey2 pubkey3 OP_3 OP_CHECKMULTISIG |
|||
return (bytes([OP_1 + m - 1]) |
|||
+ b''.join(cls.push_data(pubkey) for pubkey in pubkeys) |
|||
+ bytes([OP_1 + n - 1, OP_CHECK_MULTISIG])) |
|||
|
|||
|
|||
class Script(object): |
|||
|
|||
@classmethod |
|||
def get_ops(cls, script): |
|||
opcodes, datas = [], [] |
|||
|
|||
# The unpacks or script[n] below throw on truncated scripts |
|||
try: |
|||
n = 0 |
|||
while n < len(script): |
|||
opcode, data = script[n], None |
|||
n += 1 |
|||
|
|||
if opcode <= OpCodes.OP_PUSHDATA4: |
|||
# Raw bytes follow |
|||
if opcode < OpCodes.OP_PUSHDATA1: |
|||
dlen = opcode |
|||
elif opcode == OpCodes.OP_PUSHDATA1: |
|||
dlen = script[n] |
|||
n += 1 |
|||
elif opcode == OpCodes.OP_PUSHDATA2: |
|||
(dlen,) = struct.unpack('<H', script[n: n + 2]) |
|||
n += 2 |
|||
else: |
|||
(dlen,) = struct.unpack('<I', script[n: n + 4]) |
|||
n += 4 |
|||
data = script[n:n + dlen] |
|||
if len(data) != dlen: |
|||
raise ScriptError('truncated script') |
|||
n += dlen |
|||
|
|||
opcodes.append(opcode) |
|||
datas.append(data) |
|||
except: |
|||
# Truncated script; e.g. tx_hash |
|||
# ebc9fa1196a59e192352d76c0f6e73167046b9d37b8302b6bb6968dfd279b767 |
|||
raise ScriptError('truncated script') |
|||
|
|||
return opcodes, datas |
|||
|
|||
@classmethod |
|||
def match_ops(cls, ops, pattern): |
|||
if len(ops) != len(pattern): |
|||
return False |
|||
for op, pop in zip(ops, pattern): |
|||
if pop != op: |
|||
# -1 Indicates data push expected |
|||
if pop == -1 and OpCodes.OP_0 <= op <= OpCodes.OP_PUSHDATA4: |
|||
continue |
|||
return False |
|||
|
|||
return True |
|||
|
|||
@classmethod |
|||
def push_data(cls, data): |
|||
'''Returns the opcodes to push the data on the stack.''' |
|||
assert isinstance(data, (bytes, bytearray)) |
|||
|
|||
n = len(data) |
|||
if n < OpCodes.OP_PUSHDATA1: |
|||
return bytes([n]) + data |
|||
if n < 256: |
|||
return bytes([OpCodes.OP_PUSHDATA1, n]) + data |
|||
if n < 65536: |
|||
return bytes([OpCodes.OP_PUSHDATA2]) + struct.pack('<H', n) + data |
|||
return bytes([OpCodes.OP_PUSHDATA4]) + struct.pack('<I', n) + data |
|||
|
|||
@classmethod |
|||
def opcode_name(cls, opcode): |
|||
if OpCodes.OP_0 < opcode < OpCodes.OP_PUSHDATA1: |
|||
return 'OP_{:d}'.format(opcode) |
|||
try: |
|||
return OpCodes.whatis(opcode) |
|||
except KeyError: |
|||
return 'OP_UNKNOWN:{:d}'.format(opcode) |
|||
|
|||
@classmethod |
|||
def dump(cls, script): |
|||
opcodes, datas = cls.get_ops(script) |
|||
for opcode, data in zip(opcodes, datas): |
|||
name = cls.opcode_name(opcode) |
|||
if data is None: |
|||
print(name) |
|||
else: |
|||
print('{} {} ({:d} bytes)' |
|||
.format(name, hexlify(data).decode('ascii'), len(data))) |
@ -0,0 +1,140 @@ |
|||
# See the file "COPYING" for information about the copyright |
|||
# and warranty status of this software. |
|||
|
|||
from collections import namedtuple |
|||
import binascii |
|||
import struct |
|||
|
|||
from lib.util import cachedproperty |
|||
from lib.hash import double_sha256 |
|||
|
|||
|
|||
class Tx(namedtuple("Tx", "version inputs outputs locktime")): |
|||
|
|||
@cachedproperty |
|||
def is_coinbase(self): |
|||
return self.inputs[0].is_coinbase |
|||
|
|||
OutPoint = namedtuple("OutPoint", "hash n") |
|||
|
|||
# prevout is an OutPoint object |
|||
class TxInput(namedtuple("TxInput", "prevout script sequence")): |
|||
|
|||
ZERO = bytes(32) |
|||
MINUS_1 = 4294967295 |
|||
|
|||
@cachedproperty |
|||
def is_coinbase(self): |
|||
return self.prevout == (TxInput.ZERO, TxInput.MINUS_1) |
|||
|
|||
@cachedproperty |
|||
def script_sig_info(self): |
|||
# No meaning for coinbases |
|||
if self.is_coinbase: |
|||
return None |
|||
return Script.parse_script_sig(self.script) |
|||
|
|||
def __repr__(self): |
|||
script = binascii.hexlify(self.script).decode("ascii") |
|||
prev_hash = binascii.hexlify(self.prevout.hash).decode("ascii") |
|||
return ("Input(prevout=({}, {:d}), script={}, sequence={:d})" |
|||
.format(prev_hash, self.prevout.n, script, self.sequence)) |
|||
|
|||
|
|||
class TxOutput(namedtuple("TxOutput", "value pk_script")): |
|||
|
|||
@cachedproperty |
|||
def pay_to(self): |
|||
return Script.parse_pk_script(self.pk_script) |
|||
|
|||
|
|||
class Deserializer(object): |
|||
|
|||
def __init__(self, binary): |
|||
assert isinstance(binary, (bytes, memoryview)) |
|||
self.binary = binary |
|||
self.cursor = 0 |
|||
|
|||
def read_tx(self): |
|||
version = self.read_le_int32() |
|||
inputs = self.read_inputs() |
|||
outputs = self.read_outputs() |
|||
locktime = self.read_le_uint32() |
|||
return Tx(version, inputs, outputs, locktime) |
|||
|
|||
def read_block(self): |
|||
tx_hashes = [] |
|||
txs = [] |
|||
tx_count = self.read_varint() |
|||
for n in range(tx_count): |
|||
start = self.cursor |
|||
tx = self.read_tx() |
|||
# Note this hash needs to be reversed for human display |
|||
# For efficiency we store it in the natural serialized order |
|||
tx_hash = double_sha256(self.binary[start:self.cursor]) |
|||
tx_hashes.append(tx_hash) |
|||
txs.append(tx) |
|||
return tx_hashes, txs |
|||
|
|||
def read_inputs(self): |
|||
n = self.read_varint() |
|||
return [self.read_input() for i in range(n)] |
|||
|
|||
def read_input(self): |
|||
prevout = self.read_outpoint() |
|||
script = self.read_varbytes() |
|||
sequence = self.read_le_uint32() |
|||
return TxInput(prevout, script, sequence) |
|||
|
|||
def read_outpoint(self): |
|||
hash = self.read_nbytes(32) |
|||
n = self.read_le_uint32() |
|||
return OutPoint(hash, n) |
|||
|
|||
def read_outputs(self): |
|||
n = self.read_varint() |
|||
return [self.read_output() for i in range(n)] |
|||
|
|||
def read_output(self): |
|||
value = self.read_le_int64() |
|||
pk_script = self.read_varbytes() |
|||
return TxOutput(value, pk_script) |
|||
|
|||
def read_nbytes(self, n): |
|||
result = self.binary[self.cursor:self.cursor + n] |
|||
self.cursor += n |
|||
return result |
|||
|
|||
def read_varbytes(self): |
|||
return self.read_nbytes(self.read_varint()) |
|||
|
|||
def read_varint(self): |
|||
b = self.binary[self.cursor] |
|||
self.cursor += 1 |
|||
if b < 253: |
|||
return b |
|||
if b == 253: |
|||
return self.read_le_uint16() |
|||
if b == 254: |
|||
return self.read_le_uint32() |
|||
return self.read_le_uint64() |
|||
|
|||
def read_le_int32(self): |
|||
return self.read_format('<i') |
|||
|
|||
def read_le_int64(self): |
|||
return self.read_format('<q') |
|||
|
|||
def read_le_uint16(self): |
|||
return self.read_format('<H') |
|||
|
|||
def read_le_uint32(self): |
|||
return self.read_format('<I') |
|||
|
|||
def read_le_uint64(self): |
|||
return self.read_format('<Q') |
|||
|
|||
def read_format(self, fmt): |
|||
(result,) = struct.unpack_from(fmt, self.binary, self.cursor) |
|||
self.cursor += struct.calcsize(fmt) |
|||
return result |
@ -0,0 +1,66 @@ |
|||
# See the file "COPYING" for information about the copyright |
|||
# and warranty status of this software. |
|||
|
|||
|
|||
import sys |
|||
|
|||
|
|||
class Log(object): |
|||
'''Logging base class''' |
|||
|
|||
VERBOSE = True |
|||
|
|||
def diagnostic_name(self): |
|||
return self.__class__.__name__ |
|||
|
|||
def log(self, *msgs): |
|||
if Log.VERBOSE: |
|||
print('[{}]: '.format(self.diagnostic_name()), *msgs, |
|||
file=sys.stdout, flush=True) |
|||
|
|||
def log_error(self, *msg): |
|||
print('[{}]: ERROR: {}'.format(self.diagnostic_name()), *msgs, |
|||
file=sys.stderr, flush=True) |
|||
|
|||
|
|||
# Method decorator. To be used for calculations that will always |
|||
# deliver the same result. The method cannot take any arguments |
|||
# and should be accessed as an attribute. |
|||
class cachedproperty(object): |
|||
|
|||
def __init__(self, f): |
|||
self.f = f |
|||
|
|||
def __get__(self, obj, type): |
|||
if obj is None: |
|||
return self |
|||
value = self.f(obj) |
|||
obj.__dict__[self.f.__name__] = value |
|||
return value |
|||
|
|||
def __set__(self, obj, value): |
|||
raise AttributeError('cannot set {} on {}' |
|||
.format(self.f.__name__, obj)) |
|||
|
|||
|
|||
def chunks(items, size): |
|||
for i in range(0, len(items), size): |
|||
yield items[i: i + size] |
|||
|
|||
|
|||
def bytes_to_int(be_bytes): |
|||
'''Interprets a big-endian sequence of bytes as an integer''' |
|||
assert isinstance(be_bytes, (bytes, bytearray)) |
|||
value = 0 |
|||
for byte in be_bytes: |
|||
value = value * 256 + byte |
|||
return value |
|||
|
|||
|
|||
def int_to_bytes(value): |
|||
'''Converts an integer to a big-endian sequence of bytes''' |
|||
mods = [] |
|||
while value: |
|||
value, mod = divmod(value, 256) |
|||
mods.append(mod) |
|||
return bytes(reversed(mods)) |
@ -0,0 +1,15 @@ |
|||
The following environment variables are required: |
|||
|
|||
COIN - see lib/coins.py, must be a coin NAME |
|||
NETWORK - see lib/coins.py, must be a coin NET |
|||
DB_DIRECTORY - path to the database directory (if relative, to run script) |
|||
USERNAME - the username the server will run as |
|||
SERVER_MAIN - path to the server_main.py script (if relative, to run script) |
|||
|
|||
In addition either RPC_URL must be given as the full RPC URL for |
|||
connecting to the daemon, or you must specify RPC_HOST, RPC_USER, |
|||
RPC_PASSWORD and optionally RPC_PORT (it defaults appropriately for |
|||
the coin and network otherwise). |
|||
|
|||
The other environment variables are all optional and will adopt sensible defaults if not |
|||
specified. |
@ -0,0 +1 @@ |
|||
Bitcoin |
@ -0,0 +1 @@ |
|||
/path/to/db/directory |
@ -0,0 +1 @@ |
|||
4000000 |
@ -0,0 +1 @@ |
|||
mainnet |
@ -0,0 +1 @@ |
|||
192.168.0.1 |
@ -0,0 +1 @@ |
|||
your_daemon's_rpc_password |
@ -0,0 +1 @@ |
|||
8332 |
@ -0,0 +1 @@ |
|||
your_daemon's_rpc_username |
@ -0,0 +1 @@ |
|||
/path/to/repos/electrumx/server_main.py |
@ -0,0 +1 @@ |
|||
electrumx |
@ -0,0 +1,2 @@ |
|||
#!/bin/sh |
|||
exec multilog t s500000 n10 /path/to/log/dir |
@ -0,0 +1,3 @@ |
|||
#!/bin/sh |
|||
echo "Launching ElectrumX server..." |
|||
exec 2>&1 envdir ./env /bin/sh -c 'envuidgid $USERNAME python3 $SERVER_MAIN' |
@ -0,0 +1,470 @@ |
|||
# See the file "COPYING" for information about the copyright |
|||
# and warranty status of this software. |
|||
|
|||
import array |
|||
import itertools |
|||
import os |
|||
import struct |
|||
import time |
|||
from binascii import hexlify, unhexlify |
|||
from bisect import bisect_right |
|||
from collections import defaultdict, namedtuple |
|||
from functools import partial |
|||
import logging |
|||
|
|||
import plyvel |
|||
|
|||
from lib.coins import Bitcoin |
|||
from lib.script import ScriptPubKey |
|||
|
|||
ADDR_TX_HASH_LEN=6 |
|||
UTXO_TX_HASH_LEN=4 |
|||
HIST_ENTRY_LEN=256*4 # Admits 65536 * HIST_ENTRY_LEN/4 entries |
|||
UTXO = namedtuple("UTXO", "tx_num tx_pos tx_hash height value") |
|||
|
|||
|
|||
def to_4_bytes(value): |
|||
return struct.pack('<I', value) |
|||
|
|||
def from_4_bytes(b): |
|||
return struct.unpack('<I', b)[0] |
|||
|
|||
|
|||
class DB(object): |
|||
|
|||
HEIGHT_KEY = b'height' |
|||
TIP_KEY = b'tip' |
|||
GENESIS_KEY = b'genesis' |
|||
TX_COUNT_KEY = b'tx_count' |
|||
WALL_TIME_KEY = b'wall_time' |
|||
|
|||
class Error(Exception): |
|||
pass |
|||
|
|||
def __init__(self, env): |
|||
self.logger = logging.getLogger('DB') |
|||
self.logger.setLevel(logging.INFO) |
|||
|
|||
self.coin = env.coin |
|||
self.flush_size = env.flush_size |
|||
self.logger.info('using flush size of {:,d} entries' |
|||
.format(self.flush_size)) |
|||
|
|||
self.tx_counts = array.array('I') |
|||
self.tx_hash_file_size = 4*1024*1024 |
|||
# Unflushed items. Headers and tx_hashes have one entry per block |
|||
self.headers = [] |
|||
self.tx_hashes = [] |
|||
self.history = defaultdict(list) |
|||
self.writes_avoided = 0 |
|||
self.read_cache_hits = 0 |
|||
self.write_cache_hits = 0 |
|||
self.last_writes = 0 |
|||
self.last_time = time.time() |
|||
|
|||
# Things put in a batch are not visible until the batch is written, |
|||
# so use a cache. |
|||
# Semantics: a key/value pair in this dictionary represents the |
|||
# in-memory state of the DB. Anything in this dictionary will be |
|||
# written at the next flush. |
|||
self.write_cache = {} |
|||
# Read cache: a key/value pair in this dictionary represents |
|||
# something read from the DB; it is on-disk as of the prior |
|||
# flush. If a key is in write_cache that value is more |
|||
# recent. Any key in write_cache and not in read_cache has |
|||
# never hit the disk. |
|||
self.read_cache = {} |
|||
|
|||
db_name = '{}-{}'.format(self.coin.NAME, self.coin.NET) |
|||
try: |
|||
self.db = self.open_db(db_name, False) |
|||
except: |
|||
self.db = self.open_db(db_name, True) |
|||
self.headers_file = self.open_file('headers', True) |
|||
self.txcount_file = self.open_file('txcount', True) |
|||
self.init_db() |
|||
self.logger.info('created new database {}'.format(db_name)) |
|||
else: |
|||
self.logger.info('successfully opened database {}'.format(db_name)) |
|||
self.headers_file = self.open_file('headers') |
|||
self.txcount_file = self.open_file('txcount') |
|||
self.read_db() |
|||
|
|||
# Note that DB_HEIGHT is the height of the next block to be written. |
|||
# So an empty DB has a DB_HEIGHT of 0 not -1. |
|||
self.tx_count = self.db_tx_count |
|||
self.height = self.db_height - 1 |
|||
self.tx_counts.fromfile(self.txcount_file, self.db_height) |
|||
if self.tx_count == 0: |
|||
self.flush() |
|||
|
|||
def open_db(self, db_name, create): |
|||
return plyvel.DB(db_name, create_if_missing=create, |
|||
error_if_exists=create, |
|||
compression=None) |
|||
# lru_cache_size=256*1024*1024) |
|||
|
|||
def init_db(self): |
|||
self.db_height = 0 |
|||
self.db_tx_count = 0 |
|||
self.wall_time = 0 |
|||
self.tip = self.coin.GENESIS_HASH |
|||
self.put(self.GENESIS_KEY, unhexlify(self.tip)) |
|||
|
|||
def read_db(self): |
|||
genesis_hash = hexlify(self.get(self.GENESIS_KEY)) |
|||
if genesis_hash != self.coin.GENESIS_HASH: |
|||
raise self.Error('DB genesis hash {} does not match coin {}' |
|||
.format(genesis_hash, self.coin.GENESIS_HASH)) |
|||
self.db_height = from_4_bytes(self.get(self.HEIGHT_KEY)) |
|||
self.db_tx_count = from_4_bytes(self.get(self.TX_COUNT_KEY)) |
|||
self.wall_time = from_4_bytes(self.get(self.WALL_TIME_KEY)) |
|||
self.tip = hexlify(self.get(self.TIP_KEY)) |
|||
self.logger.info('{}/{} height: {:,d} tx count: {:,d} sync time: {}' |
|||
.format(self.coin.NAME, self.coin.NET, |
|||
self.db_height - 1, self.db_tx_count, |
|||
self.formatted_wall_time())) |
|||
|
|||
def formatted_wall_time(self): |
|||
wall_time = int(self.wall_time) |
|||
return '{:d}d {:02d}h {:02d}m {:02d}s'.format( |
|||
wall_time // 86400, (wall_time % 86400) // 3600, |
|||
(wall_time % 3600) // 60, wall_time % 60) |
|||
|
|||
def get(self, key): |
|||
# Get a key from write_cache, then read_cache, then the DB |
|||
value = self.write_cache.get(key) |
|||
if not value: |
|||
value = self.read_cache.get(key) |
|||
if not value: |
|||
value = self.db.get(key) |
|||
self.read_cache[key] = value |
|||
else: |
|||
self.read_cache_hits += 1 |
|||
else: |
|||
self.write_cache_hits += 1 |
|||
return value |
|||
|
|||
def put(self, key, value): |
|||
assert(bool(value)) |
|||
self.write_cache[key] = value |
|||
|
|||
def delete(self, key): |
|||
# Deleting an on-disk key requires a later physical delete |
|||
# If it's not on-disk we can just drop it entirely |
|||
if self.read_cache.get(key) is None: |
|||
self.writes_avoided += 1 |
|||
self.write_cache.pop(key, None) |
|||
else: |
|||
self.write_cache[key] = None |
|||
|
|||
def put_state(self): |
|||
now = time.time() |
|||
self.wall_time += now - self.last_time |
|||
self.last_time = now |
|||
self.db_tx_count = self.tx_count |
|||
self.db_height = self.height + 1 |
|||
self.put(self.HEIGHT_KEY, to_4_bytes(self.db_height)) |
|||
self.put(self.TX_COUNT_KEY, to_4_bytes(self.db_tx_count)) |
|||
self.put(self.TIP_KEY, unhexlify(self.tip)) |
|||
self.put(self.WALL_TIME_KEY, to_4_bytes(int(self.wall_time))) |
|||
|
|||
def flush(self): |
|||
# Write out the files to the FS before flushing to the DB. If |
|||
# the DB transaction fails, the files being too long doesn't |
|||
# matter. But if writing the files fails we do not want to |
|||
# have updated the DB. This disk flush is fast. |
|||
self.write_headers() |
|||
self.write_tx_counts() |
|||
self.write_tx_hashes() |
|||
|
|||
tx_diff = self.tx_count - self.db_tx_count |
|||
height_diff = self.height + 1 - self.db_height |
|||
self.logger.info('flushing to levelDB {:,d} txs and {:,d} blocks ' |
|||
'to height {:,d} tx count: {:,d}' |
|||
.format(tx_diff, height_diff, self.height, |
|||
self.tx_count)) |
|||
|
|||
# This LevelDB flush is slow |
|||
deletes = 0 |
|||
writes = 0 |
|||
with self.db.write_batch(transaction=True) as batch: |
|||
# Flush the state, then the cache, then the history |
|||
self.put_state() |
|||
for key, value in self.write_cache.items(): |
|||
if value is None: |
|||
batch.delete(key) |
|||
deletes += 1 |
|||
else: |
|||
batch.put(key, value) |
|||
writes += 1 |
|||
|
|||
self.flush_history() |
|||
|
|||
self.logger.info('flushed. Cache hits: {:,d}/{:,d} writes: {:,d} ' |
|||
'deletes: {:,d} elided: {:,d} sync: {}' |
|||
.format(self.write_cache_hits, |
|||
self.read_cache_hits, writes, deletes, |
|||
self.writes_avoided, |
|||
self.formatted_wall_time())) |
|||
|
|||
# Note this preserves semantics and hopefully saves time |
|||
self.read_cache = self.write_cache |
|||
self.write_cache = {} |
|||
self.writes_avoided = 0 |
|||
self.read_cache_hits = 0 |
|||
self.write_cache_hits = 0 |
|||
self.last_writes = writes |
|||
|
|||
def flush_history(self): |
|||
# Drop any None entry |
|||
self.history.pop(None, None) |
|||
|
|||
for hash160, hist in self.history.items(): |
|||
prefix = b'H' + hash160 |
|||
for key, v in self.db.iterator(reverse=True, prefix=prefix, |
|||
fill_cache=False): |
|||
assert len(key) == 23 |
|||
v += array.array('I', hist).tobytes() |
|||
break |
|||
else: |
|||
key = prefix + bytes(2) |
|||
v = array.array('I', hist).tobytes() |
|||
|
|||
# db.put doesn't accept a memoryview! |
|||
self.db.put(key, v[:HIST_ENTRY_LEN]) |
|||
if len(v) > HIST_ENTRY_LEN: |
|||
# must be big-endian |
|||
(idx, ) = struct.unpack('>H', key[-2:]) |
|||
for n in range(HIST_ENTRY_LEN, len(v), HIST_ENTRY_LEN): |
|||
idx += 1 |
|||
key = prefix + struct.pack('>H', idx) |
|||
if idx % 500 == 0: |
|||
addr = self.coin.P2PKH_address_from_hash160(hash160) |
|||
self.logger.info('address {} hist moving to idx {:d}' |
|||
.format(addr, idx)) |
|||
self.db.put(key, v[n:n + HIST_ENTRY_LEN]) |
|||
|
|||
self.history = defaultdict(list) |
|||
|
|||
def get_hash160(self, tx_hash, idx, delete=True): |
|||
key = b'h' + tx_hash[:ADDR_TX_HASH_LEN] + struct.pack('<H', idx) |
|||
data = self.get(key) |
|||
if data is None: |
|||
return None |
|||
|
|||
if len(data) == 24: |
|||
if delete: |
|||
self.delete(key) |
|||
return data[:20] |
|||
|
|||
# This should almost never happen |
|||
assert len(data) % 24 == 0 |
|||
self.logger.info('hash160 compressed key collision {}' |
|||
.format(key.hex())) |
|||
for n in range(0, len(data), 24): |
|||
(tx_num, ) = struct.unpack('<I', data[n+20:n+24]) |
|||
my_hash, height = self.get_tx_hash(tx_num) |
|||
if my_hash == tx_hash: |
|||
if delete: |
|||
self.put(key, data[:n] + data[n + 24:]) |
|||
return data[n:n+20] |
|||
else: |
|||
raise Exception('could not resolve hash160 collision') |
|||
|
|||
def spend_utxo(self, prevout): |
|||
hash160 = self.get_hash160(prevout.hash, prevout.n) |
|||
if hash160 is None: |
|||
# This indicates a successful spend of a non-standard script |
|||
# self.logger.info('ignoring spend of non-standard UTXO {}/{:d} ' |
|||
# 'at height {:d}' |
|||
# .format(bytes(reversed(prevout.hash)).hex(), |
|||
# prevout.n, self.height)) |
|||
return None |
|||
|
|||
key = (b'u' + hash160 + prevout.hash[:UTXO_TX_HASH_LEN] |
|||
+ struct.pack('<H', prevout.n)) |
|||
data = self.get(key) |
|||
if len(data) == 12: |
|||
(tx_num, ) = struct.unpack('<I', data[:4]) |
|||
self.delete(key) |
|||
else: |
|||
# This should almost never happen |
|||
assert len(data) % (4 + 8) == 0 |
|||
self.logger.info('UTXO compressed key collision at height {:d}, ' |
|||
'utxo {} / {:d}' |
|||
.format(self.height, bytes(reversed(prevout.hash)) |
|||
.hex(), prevout.n)) |
|||
for n in range(0, len(data), 12): |
|||
(tx_num, ) = struct.unpack('<I', data[n:n+4]) |
|||
tx_hash, height = self.get_tx_hash(tx_num) |
|||
if prevout.hash == tx_hash: |
|||
break |
|||
else: |
|||
raise Exception('could not resolve UTXO key collision') |
|||
|
|||
data = data[:n] + data[n + 12:] |
|||
self.put(key, data) |
|||
|
|||
return hash160 |
|||
|
|||
def put_utxo(self, tx_hash, idx, txout): |
|||
pk = ScriptPubKey.from_script(txout.pk_script, self.coin) |
|||
if not pk.hash160: |
|||
return None |
|||
|
|||
pack = struct.pack |
|||
idxb = pack('<H', idx) |
|||
txcb = pack('<I', self.tx_count) |
|||
|
|||
# First write the hash160 lookup |
|||
key = b'h' + tx_hash[:ADDR_TX_HASH_LEN] + idxb |
|||
# b'' avoids this annoyance: https://bugs.python.org/issue13298 |
|||
value = b''.join([pk.hash160, txcb]) |
|||
prior_value = self.get(key) |
|||
if prior_value: # Should almost never happen |
|||
value += prior_value |
|||
self.put(key, value) |
|||
|
|||
# Next write the UTXO |
|||
key = b'u' + pk.hash160 + tx_hash[:UTXO_TX_HASH_LEN] + idxb |
|||
value = txcb + pack('<Q', txout.value) |
|||
prior_value = self.get(key) |
|||
if prior_value: # Should almost never happen |
|||
value += prior_value |
|||
self.put(key, value) |
|||
|
|||
return pk.hash160 |
|||
|
|||
def open_file(self, filename, truncate=False, create=False): |
|||
try: |
|||
return open(filename, 'wb+' if truncate else 'rb+') |
|||
except FileNotFoundError: |
|||
if create: |
|||
return open(filename, 'wb+') |
|||
raise |
|||
|
|||
def read_headers(self, height, count): |
|||
header_len = self.coin.HEADER_LEN |
|||
self.headers_file.seek(height * header_len) |
|||
return self.headers_file.read(count * header_len) |
|||
|
|||
def write_headers(self): |
|||
headers = b''.join(self.headers) |
|||
header_len = self.coin.HEADER_LEN |
|||
assert len(headers) % header_len == 0 |
|||
self.headers_file.seek(self.db_height * header_len) |
|||
self.headers_file.write(headers) |
|||
self.headers_file.flush() |
|||
self.headers = [] |
|||
|
|||
def write_tx_counts(self): |
|||
self.txcount_file.seek(self.db_height * self.tx_counts.itemsize) |
|||
self.txcount_file.write(self.tx_counts[self.db_height: self.height + 1]) |
|||
self.txcount_file.flush() |
|||
|
|||
def write_tx_hashes(self): |
|||
hash_blob = b''.join(itertools.chain(*self.tx_hashes)) |
|||
assert len(hash_blob) % 32 == 0 |
|||
assert self.tx_hash_file_size % 32 == 0 |
|||
hashes = memoryview(hash_blob) |
|||
cursor = 0 |
|||
file_pos = self.db_tx_count * 32 |
|||
while cursor < len(hashes): |
|||
file_num, offset = divmod(file_pos, self.tx_hash_file_size) |
|||
size = min(len(hashes) - cursor, self.tx_hash_file_size - offset) |
|||
filename = 'hashes{:05d}'.format(file_num) |
|||
with self.open_file(filename, create=True) as f: |
|||
f.seek(offset) |
|||
f.write(hashes[cursor:cursor + size]) |
|||
cursor += size |
|||
file_pos += size |
|||
self.tx_hashes = [] |
|||
|
|||
def process_block(self, block): |
|||
self.headers.append(block[:self.coin.HEADER_LEN]) |
|||
|
|||
tx_hashes, txs = self.coin.read_block(block) |
|||
self.height += 1 |
|||
|
|||
assert len(self.tx_counts) == self.height |
|||
|
|||
# These both need to be updated before calling process_tx(). |
|||
# It uses them for tx hash lookup |
|||
self.tx_hashes.append(tx_hashes) |
|||
self.tx_counts.append(self.tx_count + len(txs)) |
|||
|
|||
for tx_hash, tx in zip(tx_hashes, txs): |
|||
self.process_tx(tx_hash, tx) |
|||
|
|||
# Flush if we're getting full |
|||
if len(self.write_cache) + len(self.history) > self.flush_size: |
|||
self.flush() |
|||
|
|||
def process_tx(self, tx_hash, tx): |
|||
hash160s = set() |
|||
if not tx.is_coinbase: |
|||
for txin in tx.inputs: |
|||
hash160s.add(self.spend_utxo(txin.prevout)) |
|||
|
|||
for idx, txout in enumerate(tx.outputs): |
|||
hash160s.add(self.put_utxo(tx_hash, idx, txout)) |
|||
|
|||
for hash160 in hash160s: |
|||
self.history[hash160].append(self.tx_count) |
|||
|
|||
self.tx_count += 1 |
|||
|
|||
def get_tx_hash(self, tx_num): |
|||
'''Returns the tx_hash and height of a tx number.''' |
|||
height = bisect_right(self.tx_counts, tx_num) |
|||
|
|||
# Is this on disk or unflushed? |
|||
if height >= self.db_height: |
|||
tx_hashes = self.tx_hashes[height - self.db_height] |
|||
tx_hash = tx_hashes[tx_num - self.tx_counts[height - 1]] |
|||
else: |
|||
file_pos = tx_num * 32 |
|||
file_num, offset = divmod(file_pos, self.tx_hash_file_size) |
|||
filename = 'hashes{:05d}'.format(file_num) |
|||
with self.open_file(filename) as f: |
|||
f.seek(offset) |
|||
tx_hash = f.read(32) |
|||
|
|||
return tx_hash, height |
|||
|
|||
def get_balance(self, hash160): |
|||
'''Returns the confirmed balance of an address.''' |
|||
utxos = self.get_utxos(hash_160) |
|||
return sum(utxo.value for utxo in utxos) |
|||
|
|||
def get_history(self, hash160): |
|||
'''Returns a sorted list of (tx_hash, height) tuples of transactions |
|||
that touched the address, earliest in the blockchain first. |
|||
Only includes outputs that have been spent. Other |
|||
transactions will be in the UTXO set. |
|||
''' |
|||
prefix = b'H' + hash160 |
|||
a = array.array('I') |
|||
for key, hist in self.db.iterator(prefix=prefix): |
|||
a.frombytes(hist) |
|||
return [self.get_tx_hash(tx_num) for tx_num in a] |
|||
|
|||
def get_utxos(self, hash160): |
|||
'''Returns all UTXOs for an address sorted such that the earliest |
|||
in the blockchain comes first. |
|||
''' |
|||
unpack = struct.unpack |
|||
prefix = b'u' + hash160 |
|||
utxos = [] |
|||
for k, v in self.db.iterator(prefix=prefix): |
|||
(tx_pos, ) = unpack('<H', k[-2:]) |
|||
|
|||
for n in range(0, len(v), 12): |
|||
(tx_num, ) = unpack('<I', v[n:n+4]) |
|||
(value, ) = unpack('<Q', v[n+4:n+12]) |
|||
tx_hash, height = self.get_tx_hash(tx_num) |
|||
utxos.append(UTXO(tx_num, tx_pos, tx_hash, height, value)) |
|||
|
|||
# Sorted by height and block position. |
|||
return sorted(utxos) |
@ -0,0 +1,54 @@ |
|||
# See the file "COPYING" for information about the copyright |
|||
# and warranty status of this software. |
|||
|
|||
import logging |
|||
from os import environ |
|||
|
|||
from lib.coins import Coin |
|||
|
|||
|
|||
class Env(object): |
|||
'''Wraps environment configuration.''' |
|||
|
|||
class Error(Exception): |
|||
pass |
|||
|
|||
def __init__(self): |
|||
self.logger = logging.getLogger('Env') |
|||
self.logger.setLevel(logging.INFO) |
|||
coin_name = self.default('COIN', 'Bitcoin') |
|||
network = self.default('NETWORK', 'mainnet') |
|||
self.coin = Coin.lookup_coin_class(coin_name, network) |
|||
self.db_dir = self.required('DB_DIRECTORY') |
|||
self.flush_size = self.integer('FLUSH_SIZE', 1000000) |
|||
self.rpc_url = self.build_rpc_url() |
|||
|
|||
def default(self, envvar, default): |
|||
return environ.get(envvar, default) |
|||
|
|||
def required(self, envvar): |
|||
value = environ.get(envvar) |
|||
if value is None: |
|||
raise self.Error('required envvar {} not set'.format(envvar)) |
|||
return value |
|||
|
|||
def integer(self, envvar, default): |
|||
value = environ.get(envvar) |
|||
if value is None: |
|||
return default |
|||
try: |
|||
return int(value) |
|||
except: |
|||
raise self.Error('cannot convert envvar {} value {} to an integer' |
|||
.format(envvar, value)) |
|||
|
|||
def build_rpc_url(self): |
|||
rpc_url = environ.get('RPC_URL') |
|||
if not rpc_url: |
|||
rpc_username = self.required('RPC_USERNAME') |
|||
rpc_password = self.required('RPC_PASSWORD') |
|||
rpc_host = self.required('RPC_HOST') |
|||
rpc_port = self.default('RPC_PORT', self.coin.DEFAULT_RPC_PORT) |
|||
rpc_url = ('http://{}:{}@{}:{}/' |
|||
.format(rpc_username, rpc_password, rpc_host, rpc_port)) |
|||
return rpc_url |
@ -0,0 +1,232 @@ |
|||
# See the file "COPYING" for information about the copyright |
|||
# and warranty status of this software. |
|||
|
|||
import asyncio |
|||
import json |
|||
import logging |
|||
import os |
|||
import signal |
|||
from functools import partial |
|||
|
|||
import aiohttp |
|||
|
|||
from server.db import DB |
|||
|
|||
|
|||
class Server(object): |
|||
|
|||
def __init__(self, env, loop): |
|||
self.env = env |
|||
self.db = DB(env) |
|||
self.rpc = RPC(env) |
|||
self.block_cache = BlockCache(env, self.db, self.rpc, loop) |
|||
|
|||
def async_tasks(self): |
|||
return [ |
|||
asyncio.ensure_future(self.block_cache.catch_up()), |
|||
asyncio.ensure_future(self.block_cache.process_cache()), |
|||
] |
|||
|
|||
|
|||
class BlockCache(object): |
|||
'''Requests blocks ahead of time from the daemon. Serves them |
|||
to the blockchain processor.''' |
|||
|
|||
def __init__(self, env, db, rpc, loop): |
|||
self.logger = logging.getLogger('BlockCache') |
|||
self.logger.setLevel(logging.INFO) |
|||
|
|||
self.db = db |
|||
self.rpc = rpc |
|||
self.stop = False |
|||
# Cache target size is in MB. Has little effect on sync time. |
|||
self.cache_limit = 10 |
|||
self.daemon_height = 0 |
|||
self.fetched_height = db.db_height |
|||
# Blocks stored in reverse order. Next block is at end of list. |
|||
self.blocks = [] |
|||
self.recent_sizes = [] |
|||
self.ave_size = 0 |
|||
for signame in ('SIGINT', 'SIGTERM'): |
|||
loop.add_signal_handler(getattr(signal, signame), |
|||
partial(self.on_signal, signame)) |
|||
|
|||
def on_signal(self, signame): |
|||
logging.warning('Received {} signal, preparing to shut down' |
|||
.format(signame)) |
|||
self.blocks = [] |
|||
self.stop = True |
|||
|
|||
async def process_cache(self): |
|||
while not self.stop: |
|||
await asyncio.sleep(1) |
|||
while self.blocks: |
|||
self.db.process_block(self.blocks.pop()) |
|||
# Release asynchronous block fetching |
|||
await asyncio.sleep(0) |
|||
|
|||
self.db.flush() |
|||
|
|||
async def catch_up(self): |
|||
self.logger.info('catching up, block cache limit {:d}MB...' |
|||
.format(self.cache_limit)) |
|||
|
|||
while await self.maybe_prefill(): |
|||
await asyncio.sleep(1) |
|||
|
|||
if not self.stop: |
|||
self.logger.info('caught up to height {:d}' |
|||
.format(self.daemon_height)) |
|||
|
|||
def cache_used(self): |
|||
return sum(len(block) for block in self.blocks) |
|||
|
|||
def prefill_count(self, room): |
|||
count = 0 |
|||
if self.ave_size: |
|||
count = room // self.ave_size |
|||
return max(count, 10) |
|||
|
|||
async def maybe_prefill(self): |
|||
'''Returns False to stop. True to sleep a while for asynchronous |
|||
processing.''' |
|||
cache_limit = self.cache_limit * 1024 * 1024 |
|||
while True: |
|||
if self.stop: |
|||
return False |
|||
|
|||
cache_used = self.cache_used() |
|||
if cache_used > cache_limit: |
|||
return True |
|||
|
|||
# Keep going by getting a whole new cache_limit of blocks |
|||
self.daemon_height = await self.rpc.rpc_single('getblockcount') |
|||
max_count = min(self.daemon_height - self.fetched_height, 4000) |
|||
count = min(max_count, self.prefill_count(cache_limit)) |
|||
if not count or self.stop: |
|||
return False # Done catching up |
|||
|
|||
# self.logger.info('requesting {:,d} blocks'.format(count)) |
|||
first = self.fetched_height + 1 |
|||
param_lists = [[height] for height in range(first, first + count)] |
|||
hashes = await self.rpc.rpc_multi('getblockhash', param_lists) |
|||
|
|||
if self.stop: |
|||
return False |
|||
|
|||
# Hashes is an array of hex strings |
|||
param_lists = [(h, False) for h in hashes] |
|||
blocks = await self.rpc.rpc_multi('getblock', param_lists) |
|||
self.fetched_height += count |
|||
|
|||
if self.stop: |
|||
return False |
|||
|
|||
# Convert hex string to bytes and put in memoryview |
|||
blocks = [memoryview(bytes.fromhex(block)) for block in blocks] |
|||
# Reverse order and place at front of list |
|||
self.blocks = list(reversed(blocks)) + self.blocks |
|||
|
|||
self.logger.info('prefilled {:,d} blocks to height {:,d} ' |
|||
'daemon height: {:,d} block cache size: {:,d}' |
|||
.format(count, self.fetched_height, |
|||
self.daemon_height, self.cache_used())) |
|||
|
|||
# Keep 50 most recent block sizes for fetch count estimation |
|||
sizes = [len(block) for block in blocks] |
|||
self.recent_sizes.extend(sizes) |
|||
excess = len(self.recent_sizes) - 50 |
|||
if excess > 0: |
|||
self.recent_sizes = self.recent_sizes[excess:] |
|||
self.ave_size = sum(self.recent_sizes) // len(self.recent_sizes) |
|||
|
|||
|
|||
class RPC(object): |
|||
|
|||
def __init__(self, env): |
|||
self.logger = logging.getLogger('RPC') |
|||
self.logger.setLevel(logging.INFO) |
|||
self.rpc_url = env.rpc_url |
|||
self.logger.info('using RPC URL {}'.format(self.rpc_url)) |
|||
|
|||
async def rpc_multi(self, method, param_lists): |
|||
payload = [{'method': method, 'params': param_list} |
|||
for param_list in param_lists] |
|||
while True: |
|||
dresults = await self.daemon(payload) |
|||
errs = [dresult['error'] for dresult in dresults] |
|||
if not any(errs): |
|||
return [dresult['result'] for dresult in dresults] |
|||
for err in errs: |
|||
if err.get('code') == -28: |
|||
self.logger.warning('daemon still warming up...') |
|||
secs = 10 |
|||
break |
|||
else: |
|||
self.logger.error('daemon returned errors: {}'.format(errs)) |
|||
secs = 0 |
|||
self.logger.info('sleeping {:d} seconds and trying again...' |
|||
.format(secs)) |
|||
await asyncio.sleep(secs) |
|||
|
|||
|
|||
async def rpc_single(self, method, params=None): |
|||
payload = {'method': method} |
|||
if params: |
|||
payload['params'] = params |
|||
while True: |
|||
dresult = await self.daemon(payload) |
|||
err = dresult['error'] |
|||
if not err: |
|||
return dresult['result'] |
|||
if err.get('code') == -28: |
|||
self.logger.warning('daemon still warming up...') |
|||
secs = 10 |
|||
else: |
|||
self.logger.error('daemon returned error: {}'.format(err)) |
|||
secs = 0 |
|||
self.logger.info('sleeping {:d} seconds and trying again...' |
|||
.format(secs)) |
|||
await asyncio.sleep(secs) |
|||
|
|||
async def daemon(self, payload): |
|||
while True: |
|||
try: |
|||
async with aiohttp.ClientSession() as session: |
|||
async with session.post(self.rpc_url, |
|||
data=json.dumps(payload)) as resp: |
|||
return await resp.json() |
|||
except Exception as e: |
|||
self.logger.error('aiohttp error: {}'.format(e)) |
|||
|
|||
self.logger.info('sleeping 1 second and trying again...') |
|||
await asyncio.sleep(1) |
|||
|
|||
# for addr in [ |
|||
# # '1dice8EMZmqKvrGE4Qc9bUFf9PX3xaYDp', |
|||
# # '1HYBcza9tVquCCvCN1hUZkYT9RcM6GfLot', |
|||
# # '1BNwxHGaFbeUBitpjy2AsKpJ29Ybxntqvb', |
|||
# # '1ARanTkswPiVM6tUEYvbskyqDsZpweiciu', |
|||
# # '1VayNert3x1KzbpzMGt2qdqrAThiRovi8', |
|||
# # '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', |
|||
# # '1XPTgDRhN8RFnzniWCddobD9iKZatrvH4', |
|||
# # '153h6eE6xRhXuN3pE53gWVfXacAtfyBF8g', |
|||
# ]: |
|||
# print('Address: ', addr) |
|||
# hash160 = coin.address_to_hash160(addr) |
|||
# utxos = self.db.get_utxos(hash160) |
|||
# for n, utxo in enumerate(utxos): |
|||
# print('UTXOs #{:d}: hash: {} pos: {:d} height: {:d} value: {:d}' |
|||
# .format(n, bytes(reversed(utxo.tx_hash)).hex(), |
|||
# utxo.tx_pos, utxo.height, utxo.value)) |
|||
|
|||
# for addr in [ |
|||
# '19k8nToWwMGuF4HkNpzgoVAYk4viBnEs5D', |
|||
# '1HaHTfmvoUW6i6nhJf8jJs6tU4cHNmBQHQ', |
|||
# '1XPTgDRhN8RFnzniWCddobD9iKZatrvH4', |
|||
# ]: |
|||
# print('Address: ', addr) |
|||
# hash160 = coin.address_to_hash160(addr) |
|||
# for n, (tx_hash, height) in enumerate(self.db.get_history(hash160)): |
|||
# print('History #{:d}: hash: {} height: {:d}' |
|||
# .format(n + 1, bytes(reversed(tx_hash)).hex(), height)) |
@ -0,0 +1,48 @@ |
|||
#!/usr/bin/env python3 |
|||
|
|||
# See the file "COPYING" for information about the copyright |
|||
# and warranty status of this software. |
|||
|
|||
import asyncio |
|||
import logging |
|||
import os |
|||
import traceback |
|||
|
|||
from server.env import Env |
|||
from server.server import Server |
|||
|
|||
|
|||
def main_loop(): |
|||
'''Get tasks; loop until complete.''' |
|||
if os.geteuid() == 0: |
|||
raise Exception('DO NOT RUN AS ROOT! Create an unpriveleged user ' |
|||
'account and use that') |
|||
|
|||
env = Env() |
|||
logging.info('switching current directory to {}'.format(env.db_dir)) |
|||
os.chdir(env.db_dir) |
|||
|
|||
loop = asyncio.get_event_loop() |
|||
try: |
|||
server = Server(env, loop) |
|||
tasks = server.async_tasks() |
|||
loop.run_until_complete(asyncio.gather(*tasks)) |
|||
finally: |
|||
loop.close() |
|||
|
|||
|
|||
def main(): |
|||
'''Set up logging, enter main loop.''' |
|||
logging.basicConfig(level=logging.INFO) |
|||
logging.info('ElectrumX server starting') |
|||
try: |
|||
main_loop() |
|||
except Exception: |
|||
traceback.print_exc() |
|||
logging.critical('ElectrumX server terminated abnormally') |
|||
else: |
|||
logging.info('ElectrumX server terminated normally') |
|||
|
|||
|
|||
if __name__ == '__main__': |
|||
main() |
Loading…
Reference in new issue