bitcoin_node 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|