bitcoin_node 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +30 -17
- data/bin/bitcoin_node +17 -0
- data/bitcoin_node.gemspec +4 -0
- data/examples/spike.rb +16 -0
- data/lib/bitcoin_node.rb +17 -2
- data/lib/bitcoin_node/p2p.rb +10 -0
- data/lib/bitcoin_node/p2p/client.rb +108 -0
- data/lib/bitcoin_node/p2p/probe.rb +43 -0
- data/lib/bitcoin_node/p2p/server.rb +63 -0
- data/lib/bitcoin_node/protocol.rb +196 -0
- data/lib/bitcoin_node/protocol/fields.rb +134 -0
- data/lib/bitcoin_node/protocol/payloads.rb +60 -0
- data/lib/bitcoin_node/version.rb +1 -1
- data/spec/integration/protocol_spec.rb +28 -0
- data/spec/integration/version_handshake_spec.rb +30 -0
- data/spec/spec_helper.rb +4 -0
- metadata +59 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 50c1b46b013075e9d27e561239c9ca3f03d50237
|
4
|
+
data.tar.gz: a165a27047b94ea084f55e761dfcfb939b3dca4e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: afea8a1965550c6c9d4b24eb6eead1895e8814d8818012759906430fc625e712b0d3d2139ce5a0f1d108b1b2a314d7d05649c4f9aff3eeccb87f2e10ea27da96
|
7
|
+
data.tar.gz: fd14859b97c3410cd825b6dce8a270eb8e00107d9946caf7093f58e12c6e4ad6a8435fc3502c458759c0836d347e8b26ddcd27a71d500df258affa5162bb4354
|
data/README.md
CHANGED
@@ -1,32 +1,45 @@
|
|
1
1
|
# BitcoinNode
|
2
2
|
|
3
|
-
Study of Bitcoin protocol by implementing a simple node
|
4
|
-
in the p2p bitcoin network
|
3
|
+
Study of Bitcoin protocol by implementing a simple node in the p2p bitcoin network.
|
5
4
|
|
6
|
-
Using as much as Ruby stdlib as possible. Main foreign dependency is Celluloid::IO
|
5
|
+
Using as much as Ruby stdlib as possible. Main foreign dependency is `Celluloid::IO`
|
7
6
|
|
8
7
|
## Installation
|
9
8
|
|
10
|
-
Add
|
9
|
+
Add to your Gemfile `gem 'bitcoin_node'` or locally run
|
11
10
|
|
12
|
-
gem
|
11
|
+
$ gem install bitcoin_node
|
13
12
|
|
14
|
-
|
13
|
+
Test
|
15
14
|
|
16
|
-
$ bundle
|
15
|
+
$ bundle exec rspec
|
17
16
|
|
18
|
-
|
17
|
+
## Usage
|
19
18
|
|
20
|
-
|
19
|
+
### Create messages
|
21
20
|
|
22
|
-
|
21
|
+
```ruby
|
22
|
+
require 'bitcoin_node'
|
23
|
+
|
24
|
+
ping = BitcoinNode::Protocol::Messages.ping
|
25
|
+
# => #<BitcoinNode::Protocol::Message:0x007feb24e1fa20 @payload=#<Ping {:nonce=>#<struct Integer64Field 12031756400052209357>}>, @command="ping">
|
26
|
+
|
27
|
+
ping.raw
|
28
|
+
# => "\xF9\xBE\xB4\xD9ping\x00\x00\x00\x00\x00\x00\x00\x00\b\x00\x00\x00\xAB\x0F\x0FZ\x95\xDC{\xA1\xB1i\x11]"
|
29
|
+
|
30
|
+
version = BN::Protocol::Messages.version
|
31
|
+
```
|
32
|
+
|
33
|
+
### Single client
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
require 'bitcoin_node'
|
23
37
|
|
24
|
-
|
38
|
+
host = '144.76.217.165'
|
25
39
|
|
26
|
-
|
40
|
+
client = BN::P2P::Client.connect(host)
|
27
41
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
5. Create a new Pull Request
|
42
|
+
client.send(BN::Protocol::Messages.version)
|
43
|
+
client.send(BN::Protocol::Messages.ping)
|
44
|
+
client.send(BN::Protocol::Messages.getaddr)
|
45
|
+
```
|
data/bin/bitcoin_node
CHANGED
@@ -1,3 +1,20 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
3
|
require 'bitcoin_node'
|
4
|
+
require 'resolv'
|
5
|
+
|
6
|
+
SEED = 'seed.bitcoin.sipa.be'
|
7
|
+
hosts = Resolv::DNS.new.getaddresses(SEED).map(&:to_s).sample(4)
|
8
|
+
|
9
|
+
threads = hosts.map do |host|
|
10
|
+
Thread.new(host) do |h|
|
11
|
+
client = BN::P2p::Client.connect(h)
|
12
|
+
|
13
|
+
client.send(BN::P::Messages.version)
|
14
|
+
client.send(BN::P::Messages.ping)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
threads.map(&:join)
|
19
|
+
|
20
|
+
sleep(20)
|
data/bitcoin_node.gemspec
CHANGED
@@ -18,6 +18,10 @@ Gem::Specification.new do |spec|
|
|
18
18
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
19
|
spec.require_paths = ["lib"]
|
20
20
|
|
21
|
+
spec.add_dependency "celluloid-io"
|
22
|
+
|
21
23
|
spec.add_development_dependency "bundler", "~> 1.6"
|
22
24
|
spec.add_development_dependency "rake"
|
25
|
+
spec.add_development_dependency "rspec"
|
26
|
+
spec.add_development_dependency "pry"
|
23
27
|
end
|
data/examples/spike.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
# encoding: ascii-8bit
|
2
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
3
|
+
|
4
|
+
require 'bitcoin_node'
|
5
|
+
|
6
|
+
host = ARGV[0] || (abort 'Missing host')
|
7
|
+
|
8
|
+
payload = BN::Protocol::Version.new(addr_recv: [host, 8333])
|
9
|
+
|
10
|
+
message = BN::Protocol::Message.new(payload)
|
11
|
+
|
12
|
+
client = BN::P2P::Client.connect(host)
|
13
|
+
client.send(message)
|
14
|
+
client.send(BN::Protocol::Messages.ping)
|
15
|
+
client.send(BN::Protocol::Messages.getaddr)
|
16
|
+
|
data/lib/bitcoin_node.rb
CHANGED
@@ -1,5 +1,20 @@
|
|
1
|
-
|
1
|
+
# coding: ascii-8bit
|
2
|
+
require 'logger'
|
3
|
+
|
4
|
+
require 'bitcoin_node/version'
|
5
|
+
require 'bitcoin_node/protocol'
|
6
|
+
require 'bitcoin_node/p2p'
|
2
7
|
|
3
8
|
module BitcoinNode
|
4
|
-
|
9
|
+
NETWORKS = { main: "\xF9\xBE\xB4\xD9".freeze, testnet: "\xFA\xBF\xB5\xDA".freeze }
|
10
|
+
|
11
|
+
def self.network
|
12
|
+
NETWORKS[:main]
|
13
|
+
end
|
14
|
+
|
15
|
+
Logger = ::Logger.new(STDOUT)
|
16
|
+
Logger.progname = 'NODE'
|
5
17
|
end
|
18
|
+
|
19
|
+
BN = BitcoinNode
|
20
|
+
BN::P = BN::Protocol
|
@@ -0,0 +1,108 @@
|
|
1
|
+
require 'socket'
|
2
|
+
|
3
|
+
module BitcoinNode
|
4
|
+
module P2p
|
5
|
+
class Client
|
6
|
+
def self.connect(host, port = 8333, probe = LoggingProbe.new("client-#{host}"))
|
7
|
+
new(host, port, probe)
|
8
|
+
end
|
9
|
+
|
10
|
+
attr_accessor :handshaked, :version
|
11
|
+
|
12
|
+
def initialize(host, port = 8333, probe = LoggingProbe.new("client-#{host}"))
|
13
|
+
@host, @buffer, @probe = host, String.new, probe
|
14
|
+
@socket = TCPSocket.new(host, port)
|
15
|
+
@handshaked = false
|
16
|
+
@version = BN::Protocol::VERSION
|
17
|
+
@probe << { connected: host }
|
18
|
+
end
|
19
|
+
|
20
|
+
alias_method :handshaked?, :handshaked
|
21
|
+
|
22
|
+
def send(message)
|
23
|
+
raise ArgumentError unless BN::Protocol::Message === message
|
24
|
+
|
25
|
+
@probe << { sending: message.command }
|
26
|
+
@socket.write(message.raw)
|
27
|
+
|
28
|
+
loop {
|
29
|
+
@buffer << @socket.readpartial(1024)
|
30
|
+
handler = CommandHandler.new(self, @buffer, @probe)
|
31
|
+
if handler.valid_message?
|
32
|
+
handler.parse
|
33
|
+
break
|
34
|
+
end
|
35
|
+
}
|
36
|
+
rescue IOError => e
|
37
|
+
BN::ClientLogger.error(e.message)
|
38
|
+
end
|
39
|
+
|
40
|
+
def close!
|
41
|
+
@socket.close
|
42
|
+
@probe << { closed: @host }
|
43
|
+
end
|
44
|
+
|
45
|
+
class CommandHandler
|
46
|
+
def initialize(client, buffer, probe)
|
47
|
+
@client, @buffer, @probe = client, buffer, probe
|
48
|
+
end
|
49
|
+
|
50
|
+
def parse
|
51
|
+
@probe << { receiving: @command }
|
52
|
+
|
53
|
+
callback = Parser.new(@command, @payload).parse
|
54
|
+
|
55
|
+
@buffer.clear
|
56
|
+
callback.call(@client)
|
57
|
+
end
|
58
|
+
|
59
|
+
def valid_message?
|
60
|
+
@payload, @command = BN::Protocol::Message.validate(@buffer)
|
61
|
+
rescue BN::P::IncompleteMessageError
|
62
|
+
false
|
63
|
+
rescue BN::P::InvalidChecksumError => e
|
64
|
+
BN::Logger.info(e.message)
|
65
|
+
false
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
class Parser
|
70
|
+
def initialize(command, payload)
|
71
|
+
@command, @payload = command, payload
|
72
|
+
end
|
73
|
+
|
74
|
+
def parse
|
75
|
+
callback = Proc.new {}
|
76
|
+
|
77
|
+
if @command == 'version'
|
78
|
+
received = BN::Protocol::Version.parse(@payload)
|
79
|
+
remote_protocol_version = received.protocol_version.value
|
80
|
+
callback = lambda do |client|
|
81
|
+
client.version = [remote_protocol_version, client.version].min
|
82
|
+
client.send(BN::Protocol::Messages.verack)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
if @command == 'verack'
|
87
|
+
BN::Logger.info('Version handshake finished')
|
88
|
+
callback = lambda { |client| client.handshaked = true }
|
89
|
+
end
|
90
|
+
|
91
|
+
if @command == 'addr'
|
92
|
+
BN::Logger.info('Parsing addresses')
|
93
|
+
BN::Protocol::Addr.parse(@payload)
|
94
|
+
end
|
95
|
+
|
96
|
+
if @command == 'inv'
|
97
|
+
BN::Logger.info('Parsing inv')
|
98
|
+
p BN::Protocol::Inv.parse(@payload)
|
99
|
+
end
|
100
|
+
|
101
|
+
callback
|
102
|
+
end
|
103
|
+
|
104
|
+
end
|
105
|
+
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module BitcoinNode
|
2
|
+
module P2p
|
3
|
+
class NullProbe
|
4
|
+
def <<(*args); end
|
5
|
+
end
|
6
|
+
|
7
|
+
class StoreProbe
|
8
|
+
attr_reader :store
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@store = Hash.new { |h, key| h[key] = [] }
|
12
|
+
end
|
13
|
+
|
14
|
+
def <<(hash)
|
15
|
+
hash.each do |key, value|
|
16
|
+
store[key] << value
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class LoggingProbe
|
22
|
+
attr_reader :logger
|
23
|
+
|
24
|
+
def initialize(progname)
|
25
|
+
@logger = ::Logger.new(STDOUT)
|
26
|
+
@logger.progname = progname.upcase
|
27
|
+
end
|
28
|
+
|
29
|
+
def <<(hash)
|
30
|
+
hash.each do |key, value|
|
31
|
+
case key
|
32
|
+
when :sending then logger.info("Sending #{value}")
|
33
|
+
when :receiving then logger.info("Receiving #{value}")
|
34
|
+
when :connected then logger.info("Connected to #{value}")
|
35
|
+
when :connection then logger.info("Connection received from #{value}")
|
36
|
+
when :closed then logger.info("Closed connection to #{value}")
|
37
|
+
else logger.unknown('Cannot log that!!')
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'celluloid/io'
|
3
|
+
|
4
|
+
module BitcoinNode
|
5
|
+
module P2p
|
6
|
+
class Server
|
7
|
+
include Celluloid::IO
|
8
|
+
finalizer :shutdown
|
9
|
+
|
10
|
+
def initialize(port = 3333, probe = LoggingProbe.new('server'))
|
11
|
+
@server = TCPServer.new('localhost', port)
|
12
|
+
@probe = probe
|
13
|
+
async.run
|
14
|
+
end
|
15
|
+
|
16
|
+
def run
|
17
|
+
loop { async.accept_connection @server.accept }
|
18
|
+
end
|
19
|
+
|
20
|
+
def shutdown
|
21
|
+
@server.close if @server
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def accept_connection(socket)
|
27
|
+
_, port, host = socket.peeraddr
|
28
|
+
@probe << { connection: "#{host}:#{port}" }
|
29
|
+
|
30
|
+
loop { handle_messages(socket) }
|
31
|
+
end
|
32
|
+
|
33
|
+
def handle_messages(socket)
|
34
|
+
_, port, host = socket.peeraddr
|
35
|
+
response = socket.readpartial(1024)
|
36
|
+
network, command, length, checksum = response.unpack('a4A12Va4')
|
37
|
+
payload = response[24...(24 + length)]
|
38
|
+
@probe << { receiving: command }
|
39
|
+
|
40
|
+
if command == 'version'
|
41
|
+
payload = BN::Protocol::Version.new(addr_recv: ['127.0.0.1', port])
|
42
|
+
message = BN::Protocol::Message.new(payload)
|
43
|
+
@probe << { sending: 'version' }
|
44
|
+
socket.write(message.raw)
|
45
|
+
end
|
46
|
+
|
47
|
+
if command == 'verack'
|
48
|
+
verack = BN::Protocol::Messages.verack
|
49
|
+
@probe << { sending: 'verack' }
|
50
|
+
socket.write(verack.raw)
|
51
|
+
end
|
52
|
+
|
53
|
+
if command == 'ping'
|
54
|
+
ping_nonce = BN::Protocol::Ping.parse(payload).nonce.value
|
55
|
+
pong = BN::Protocol::Messages.pong(ping_nonce)
|
56
|
+
@probe << { sending: 'pong' }
|
57
|
+
socket.write(pong.raw)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,196 @@
|
|
1
|
+
# coding: ascii-8bit
|
2
|
+
require 'digest'
|
3
|
+
|
4
|
+
module BitcoinNode
|
5
|
+
module Protocol
|
6
|
+
|
7
|
+
VERSION = 70001
|
8
|
+
|
9
|
+
MessageParsingError = Class.new(StandardError)
|
10
|
+
IncompleteMessageError = Class.new(MessageParsingError)
|
11
|
+
InvalidChecksumError = Class.new(MessageParsingError)
|
12
|
+
|
13
|
+
def self.digest(content)
|
14
|
+
Digest::SHA256.digest(Digest::SHA256.digest(content))
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.nonce
|
18
|
+
rand(0xffffffffffffffff)
|
19
|
+
end
|
20
|
+
|
21
|
+
class Header
|
22
|
+
SIZE = 24
|
23
|
+
|
24
|
+
def self.build_from(payload)
|
25
|
+
new(payload)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.unpack(raw)
|
29
|
+
raw.unpack('a4A12Va4')
|
30
|
+
end
|
31
|
+
|
32
|
+
def initialize(payload)
|
33
|
+
@payload = payload
|
34
|
+
end
|
35
|
+
private_class_method :new
|
36
|
+
|
37
|
+
def raw
|
38
|
+
@raw ||= begin
|
39
|
+
[BitcoinNode.network,
|
40
|
+
@payload.name.ljust(12, "\x00")[0...12],
|
41
|
+
[@payload.bytesize].pack("V"),
|
42
|
+
BN::Protocol.digest(@payload.raw)[0...4]].join
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
class Message
|
48
|
+
attr_reader :command
|
49
|
+
|
50
|
+
def initialize(payload)
|
51
|
+
raise ArgumentError, 'Expected Payload type' unless Payload === payload
|
52
|
+
@payload = payload
|
53
|
+
@command = payload.name
|
54
|
+
end
|
55
|
+
|
56
|
+
def raw
|
57
|
+
@raw ||= begin
|
58
|
+
[Header.build_from(@payload).raw, @payload.raw]
|
59
|
+
.join
|
60
|
+
.force_encoding('binary')
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def bytesize
|
65
|
+
raw.bytesize
|
66
|
+
end
|
67
|
+
|
68
|
+
def self.validate(raw_message)
|
69
|
+
network, command, expected_length, checksum = Header.unpack(raw_message)
|
70
|
+
raw_payload = raw_message[Header::SIZE...(Header::SIZE + expected_length)]
|
71
|
+
if (actual = raw_payload.bytesize) < expected_length
|
72
|
+
raise BN::P::IncompleteMessageError.new("Incomplete message (missing #{expected_length - actual} bytes)")
|
73
|
+
elsif checksum != BN::Protocol.digest(raw_payload)[0...4]
|
74
|
+
raise BN::P::InvalidChecksumError.new("Invalid checksum on command #{command}")
|
75
|
+
else
|
76
|
+
[raw_payload, command]
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
module Messages
|
82
|
+
module_function
|
83
|
+
|
84
|
+
def ping
|
85
|
+
BN::P::Message.new(BN::Protocol::Ping.new)
|
86
|
+
end
|
87
|
+
|
88
|
+
def pong(nonce)
|
89
|
+
BN::P::Message.new(BN::Protocol::Pong.new(nonce: nonce))
|
90
|
+
end
|
91
|
+
|
92
|
+
def verack
|
93
|
+
BN::P::Message.new(BN::Protocol::Verack.new)
|
94
|
+
end
|
95
|
+
|
96
|
+
def version
|
97
|
+
BN::P::Message.new(BN::Protocol::Version.new)
|
98
|
+
end
|
99
|
+
|
100
|
+
def getaddr
|
101
|
+
BN::P::Message.new(BN::Protocol::Getaddr.new)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
class Payload
|
106
|
+
class << self
|
107
|
+
def field(name, type, options = {})
|
108
|
+
define_method(name) do
|
109
|
+
instance_fields[name]
|
110
|
+
end
|
111
|
+
|
112
|
+
define_method("#{name}=") do |value|
|
113
|
+
if type === value
|
114
|
+
instance_fields[name] = value
|
115
|
+
else
|
116
|
+
instance_fields[name] = type.new(*Array(value))
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
fields[name] = type
|
121
|
+
defaults[name] = options[:default] if options[:default]
|
122
|
+
end
|
123
|
+
|
124
|
+
def defaults
|
125
|
+
@defaults ||= {}
|
126
|
+
end
|
127
|
+
|
128
|
+
def fields
|
129
|
+
@fields ||= {}
|
130
|
+
end
|
131
|
+
|
132
|
+
def field_names
|
133
|
+
fields.keys
|
134
|
+
end
|
135
|
+
|
136
|
+
def parse(payload)
|
137
|
+
result = fields.inject({}) do |memo, (field_name, type)|
|
138
|
+
custom_parse_method = "parse_#{field_name.to_s}"
|
139
|
+
parsed, payload = if respond_to?(custom_parse_method)
|
140
|
+
public_send(custom_parse_method, payload, memo)
|
141
|
+
else
|
142
|
+
type.parse(payload)
|
143
|
+
end
|
144
|
+
memo[field_name] = parsed
|
145
|
+
memo
|
146
|
+
end
|
147
|
+
new(result)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def initialize(attributes = {})
|
152
|
+
attributes.each do |k,v|
|
153
|
+
self.send("#{k}=", v)
|
154
|
+
end
|
155
|
+
missings = self.class.field_names - attributes.keys
|
156
|
+
missings.each do |k|
|
157
|
+
d = self.class.defaults[k]
|
158
|
+
self.send("#{k}=", Proc === d ? d.call : d)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def raw
|
163
|
+
@raw ||= begin
|
164
|
+
ordered = instance_fields.values_at(*self.class.field_names)
|
165
|
+
ordered.map(&:pack).join
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def bytesize
|
170
|
+
raw.bytesize
|
171
|
+
end
|
172
|
+
|
173
|
+
def type
|
174
|
+
self.class.name.split('::').last
|
175
|
+
end
|
176
|
+
|
177
|
+
def name
|
178
|
+
type.downcase
|
179
|
+
end
|
180
|
+
|
181
|
+
def to_s
|
182
|
+
"#<#{type} #{@instance_fields.inspect}>"
|
183
|
+
end
|
184
|
+
alias_method :inspect, :to_s
|
185
|
+
|
186
|
+
private
|
187
|
+
|
188
|
+
def instance_fields
|
189
|
+
@instance_fields ||= {}
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
require 'bitcoin_node/protocol/fields'
|
196
|
+
require 'bitcoin_node/protocol/payloads'
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# coding: ascii-8bit
|
2
|
+
require 'digest'
|
3
|
+
|
4
|
+
module BitcoinNode
|
5
|
+
module Protocol
|
6
|
+
|
7
|
+
class FieldStruct < Struct
|
8
|
+
def to_s
|
9
|
+
type = self.class.name ? self.class.name.split('::').last : ''
|
10
|
+
"#<struct #{type} #{values.join(', ')}>"
|
11
|
+
end
|
12
|
+
alias_method :inspect, :to_s
|
13
|
+
end
|
14
|
+
|
15
|
+
AddressField = FieldStruct.new(:host, :port) do
|
16
|
+
def pack
|
17
|
+
sockaddr = Socket.pack_sockaddr_in(port, host)
|
18
|
+
p, h = sockaddr[2...4], sockaddr[4...8]
|
19
|
+
[[1].pack('Q'), "\x00" * 10, "\xFF\xFF", h, p].join
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.parse(address)
|
23
|
+
ip, port, remain = address.unpack("x8x12a4na*")
|
24
|
+
[new(ip.unpack("C*").join('.'), port), remain]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
TimedAddressField = FieldStruct.new(:time, :service, :host, :port) do
|
29
|
+
def pack
|
30
|
+
end
|
31
|
+
|
32
|
+
def alive?
|
33
|
+
(Time.now.tv_sec - 10800) <= time
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.parse(address)
|
37
|
+
time, service, ip, port, remain = address.unpack('VQx12a4na*')
|
38
|
+
[new(time, service, ip.unpack("C*").join('.'), port), remain]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
AddressesListField = FieldStruct.new(:count) do
|
43
|
+
def self.parse(count, payload)
|
44
|
+
Array.new(count) do
|
45
|
+
addr, payload = TimedAddressField.parse(payload)
|
46
|
+
addr
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
VariableIntegerField = FieldStruct.new(:value) do
|
52
|
+
def pack
|
53
|
+
if value < 0xfd; [value].pack("C")
|
54
|
+
elsif value <= 0xffff; [0xfd, value].pack("Cv")
|
55
|
+
elsif value <= 0xffffffff; [0xfe, value].pack("CV")
|
56
|
+
elsif value <= 0xffffffffffffffff; [0xff, value].pack("CQ")
|
57
|
+
else raise "Cannot pack integer #{value}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.parse(raw)
|
62
|
+
case raw.unpack("C")[0]
|
63
|
+
when 0xfd; raw.unpack("xva*")
|
64
|
+
when 0xfe; raw.unpack("xVa*")
|
65
|
+
when 0xff; raw.unpack("xQa*")
|
66
|
+
else; raw.unpack("Ca*")
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
InventoryVectorField = FieldStruct.new(:type, :object_hash) do
|
72
|
+
|
73
|
+
TYPES = { ERROR: 0, MSG_TX: 1, MSG_BLOCK: 2, MSG_FILTERED_BLOCK: 3 }
|
74
|
+
|
75
|
+
def pack
|
76
|
+
[TYPES.fetch(type), object_hash].pack('Va32')
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.parse(raw)
|
80
|
+
type_int, object_hash, remain = raw.unpack('Va32a*')
|
81
|
+
[new(TYPES.invert.fetch(type_int), object_hash), remain]
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
Integer32Field = FieldStruct.new(:value) do
|
86
|
+
def pack
|
87
|
+
[value].pack('V')
|
88
|
+
end
|
89
|
+
|
90
|
+
def self.parse(raw)
|
91
|
+
i, remain = raw.unpack('Va*')
|
92
|
+
[new(i), remain]
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
StringField = FieldStruct.new(:value) do
|
97
|
+
def pack
|
98
|
+
"#{VariableIntegerField.new(value.bytesize).pack}#{value}"
|
99
|
+
end
|
100
|
+
|
101
|
+
def self.parse(raw)
|
102
|
+
size, payload = VariableIntegerField.parse(raw)
|
103
|
+
if size > 0
|
104
|
+
v, payload = payload.unpack("a#{size}a*")
|
105
|
+
[StringField.new(v), payload]
|
106
|
+
else
|
107
|
+
[nil, payload]
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
Integer64Field = FieldStruct.new(:value) do
|
113
|
+
def pack
|
114
|
+
[value].pack('Q')
|
115
|
+
end
|
116
|
+
|
117
|
+
def self.parse(raw)
|
118
|
+
i, remain = raw.unpack('Qa*')
|
119
|
+
[new(i), remain]
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
BooleanField = FieldStruct.new(:value) do
|
124
|
+
def pack
|
125
|
+
(value == true) ? [0xFF].pack("C") : [0x00].pack("C")
|
126
|
+
end
|
127
|
+
|
128
|
+
def self.parse(raw)
|
129
|
+
b, remain = raw.unpack('C')
|
130
|
+
[BooleanField.new(b == 0 ? false : true), remain]
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module BitcoinNode
|
2
|
+
module Protocol
|
3
|
+
class Version < Payload
|
4
|
+
field :protocol_version, Integer32Field, default: BitcoinNode::Protocol::VERSION
|
5
|
+
field :services, Integer64Field, default: 1
|
6
|
+
field :timestamp, Integer64Field, default: lambda { Time.now.tv_sec }
|
7
|
+
field :addr_recv, AddressField, default: ['127.0.0.1', 3333]
|
8
|
+
field :addr_from, AddressField, default: ['127.0.0.1', 8333]
|
9
|
+
field :nonce, Integer64Field, default: lambda { BN::Protocol.nonce }
|
10
|
+
field :user_agent, StringField, default: "/bitcoin_node:#{BitcoinNode::VERSION}/"
|
11
|
+
field :start_height, Integer32Field, default: 0
|
12
|
+
field :relay, BooleanField, default: true
|
13
|
+
|
14
|
+
def self.parse_relay(payload, fields_parsed)
|
15
|
+
parsed_version = fields_parsed.fetch(:protocol_version).value
|
16
|
+
parsed_version >= BitcoinNode::Protocol::VERSION ? BooleanField.parse(payload) : true
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
|
21
|
+
class Addr < Payload
|
22
|
+
field :count, VariableIntegerField
|
23
|
+
field :addr_list, AddressesListField
|
24
|
+
|
25
|
+
def self.parse(raw)
|
26
|
+
count, payload = VariableIntegerField.parse(raw)
|
27
|
+
AddressesListField.parse(count, payload)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class Inv < Payload
|
32
|
+
field :count, VariableIntegerField
|
33
|
+
field :inventory_list, InventoryVectorField
|
34
|
+
|
35
|
+
def self.parse(raw)
|
36
|
+
count, payload = VariableIntegerField.parse(raw)
|
37
|
+
invs = Array.new(count) do
|
38
|
+
inv, payload = InventoryVectorField.parse(payload)
|
39
|
+
inv
|
40
|
+
end
|
41
|
+
new(count: count, inventory_list: invs)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
class Verack < Payload; end
|
46
|
+
|
47
|
+
class Getaddr < Payload; end
|
48
|
+
|
49
|
+
class Ping < Payload
|
50
|
+
field :nonce, Integer64Field, default: lambda { BN::Protocol.nonce }
|
51
|
+
end
|
52
|
+
|
53
|
+
class Pong < Payload
|
54
|
+
field :nonce, Integer64Field
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
|
data/lib/bitcoin_node/version.rb
CHANGED
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'Protocol messages' do
|
4
|
+
|
5
|
+
it 'sends and parses version correctly' do
|
6
|
+
now = Time.now.tv_sec
|
7
|
+
|
8
|
+
version = BN::Protocol::Version.new(
|
9
|
+
timestamp: now, addr_recv: ['127.0.0.0', 8333],
|
10
|
+
addr_from: ['192.168.0.1', 45555],
|
11
|
+
start_height: 127953,
|
12
|
+
relay: false,
|
13
|
+
)
|
14
|
+
|
15
|
+
parsed_version = BN::Protocol::Version.parse(version.raw)
|
16
|
+
|
17
|
+
expect(parsed_version.protocol_version.value).to eql 70001
|
18
|
+
expect(parsed_version.services.value).to eql 1
|
19
|
+
expect(parsed_version.timestamp).to eql(version.timestamp)
|
20
|
+
expect(parsed_version.addr_recv).to eql(version.addr_recv)
|
21
|
+
expect(parsed_version.addr_from).to eql(version.addr_from)
|
22
|
+
expect(parsed_version.nonce).to eql(version.nonce)
|
23
|
+
expect(parsed_version.user_agent.value).to eql("/bitcoin_node:#{BitcoinNode::VERSION}/")
|
24
|
+
expect(parsed_version.start_height).to eql(version.start_height)
|
25
|
+
expect(parsed_version.relay).to eql(version.relay)
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'Version handshake' do
|
4
|
+
|
5
|
+
let(:port) { 3333 }
|
6
|
+
|
7
|
+
it 'peers exchanges version properly' do
|
8
|
+
server = BN::P2p::Server.new
|
9
|
+
|
10
|
+
client_probe = BN::P2p::StoreProbe.new
|
11
|
+
client = BN::P2p::Client.connect('localhost', port, client_probe)
|
12
|
+
client.version = 60001
|
13
|
+
|
14
|
+
payload = BN::Protocol::Version.new(addr_recv: ['127.0.0.1', port])
|
15
|
+
message = BN::Protocol::Message.new(payload)
|
16
|
+
|
17
|
+
expect(client.handshaked?).to eql false
|
18
|
+
|
19
|
+
client.send(message)
|
20
|
+
|
21
|
+
expect(client.handshaked?).to eql true
|
22
|
+
expect(client.version).to eql 60001
|
23
|
+
|
24
|
+
client.send(BN::Protocol::Messages.ping)
|
25
|
+
|
26
|
+
expect(client_probe.store[:sending]).to eql %w(version verack ping)
|
27
|
+
expect(client_probe.store[:receiving]).to eql %w(version verack pong)
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: bitcoin_node
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- simcap
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2014-
|
11
|
+
date: 2014-11-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: celluloid-io
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
13
27
|
- !ruby/object:Gem::Dependency
|
14
28
|
name: bundler
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -38,6 +52,34 @@ dependencies:
|
|
38
52
|
- - ">="
|
39
53
|
- !ruby/object:Gem::Version
|
40
54
|
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: pry
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
41
83
|
description: Simple node on the p2p bitcoin network
|
42
84
|
email:
|
43
85
|
- simcap@fastmail.com
|
@@ -53,8 +95,19 @@ files:
|
|
53
95
|
- Rakefile
|
54
96
|
- bin/bitcoin_node
|
55
97
|
- bitcoin_node.gemspec
|
98
|
+
- examples/spike.rb
|
56
99
|
- lib/bitcoin_node.rb
|
100
|
+
- lib/bitcoin_node/p2p.rb
|
101
|
+
- lib/bitcoin_node/p2p/client.rb
|
102
|
+
- lib/bitcoin_node/p2p/probe.rb
|
103
|
+
- lib/bitcoin_node/p2p/server.rb
|
104
|
+
- lib/bitcoin_node/protocol.rb
|
105
|
+
- lib/bitcoin_node/protocol/fields.rb
|
106
|
+
- lib/bitcoin_node/protocol/payloads.rb
|
57
107
|
- lib/bitcoin_node/version.rb
|
108
|
+
- spec/integration/protocol_spec.rb
|
109
|
+
- spec/integration/version_handshake_spec.rb
|
110
|
+
- spec/spec_helper.rb
|
58
111
|
homepage: ''
|
59
112
|
licenses:
|
60
113
|
- MIT
|
@@ -79,4 +132,7 @@ rubygems_version: 2.2.2
|
|
79
132
|
signing_key:
|
80
133
|
specification_version: 4
|
81
134
|
summary: Simple node on the p2p bitcoin network
|
82
|
-
test_files:
|
135
|
+
test_files:
|
136
|
+
- spec/integration/protocol_spec.rb
|
137
|
+
- spec/integration/version_handshake_spec.rb
|
138
|
+
- spec/spec_helper.rb
|