From 2e14b4997f43130a92b60d41b8dc8dbb6e269ae0 Mon Sep 17 00:00:00 2001 From: classicalliu Date: Fri, 22 Mar 2019 10:33:11 +0800 Subject: [PATCH] feat: add basic wallet --- .rubocop.yml | 6 ++ Gemfile.lock | 7 ++ ckb-sdk-ruby.gemspec | 3 + lib/ckb.rb | 3 + lib/ckb/blake2b.rb | 36 ++++++++++ lib/ckb/rpc.rb | 16 +++++ lib/ckb/utils.rb | 100 ++++++++++++++++++++++++++++ lib/ckb/wallet.rb | 145 +++++++++++++++++++++++++++++++++++++++++ spec/ckb/utils_spec.rb | 60 +++++++++++++++++ 9 files changed, 376 insertions(+) create mode 100644 lib/ckb/blake2b.rb create mode 100644 lib/ckb/utils.rb create mode 100644 lib/ckb/wallet.rb create mode 100644 spec/ckb/utils_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 8025a611..d29dc876 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -30,3 +30,9 @@ Metrics/AbcSize: Metrics/MethodLength: CountComments: false Max: 50 + +Metrics/LineLength: + Max: 120 + +Metrics/ClassLength: + Max: 120 diff --git a/Gemfile.lock b/Gemfile.lock index dc1406c5..6d8cd63f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,13 +2,18 @@ PATH remote: . specs: ckb-sdk-ruby (0.1.0) + bitcoin-secp256k1 (~> 0.5.0) + rbnacl (~> 6.0, >= 6.0.1) GEM remote: https://rubygems.org/ specs: ast (2.4.0) + bitcoin-secp256k1 (0.5.0) + ffi (>= 1.9.25) coderay (1.1.2) diff-lcs (1.3) + ffi (1.10.0) jaro_winkler (1.5.2) method_source (0.9.2) parallel (1.14.0) @@ -20,6 +25,8 @@ GEM psych (3.1.0) rainbow (3.0.0) rake (10.5.0) + rbnacl (6.0.1) + ffi rspec (3.8.0) rspec-core (~> 3.8.0) rspec-expectations (~> 3.8.0) diff --git a/ckb-sdk-ruby.gemspec b/ckb-sdk-ruby.gemspec index 993af649..79309a62 100644 --- a/ckb-sdk-ruby.gemspec +++ b/ckb-sdk-ruby.gemspec @@ -41,4 +41,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rspec", "~> 3.0" spec.add_development_dependency "rubocop", "~> 0.66.0" spec.add_development_dependency "pry", "~> 0.12.2" + + spec.add_dependency "rbnacl", "~> 6.0", ">= 6.0.1" + spec.add_dependency "bitcoin-secp256k1", "~> 0.5.0" end diff --git a/lib/ckb.rb b/lib/ckb.rb index caf97b97..6143f45b 100644 --- a/lib/ckb.rb +++ b/lib/ckb.rb @@ -2,6 +2,9 @@ require "ckb/version" require "ckb/rpc" +require "ckb/blake2b" +require "ckb/utils" +require "ckb/wallet" module CKB class Error < StandardError; end diff --git a/lib/ckb/blake2b.rb b/lib/ckb/blake2b.rb new file mode 100644 index 00000000..3eac16ad --- /dev/null +++ b/lib/ckb/blake2b.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rbnacl" + +module CKB + class Blake2b + DEFAULT_OPTIONS = { + personal: "ckb-default-hash", + digest_size: 32 + }.freeze + + def initialize(_opts = {}) + @blake2b = self.class.generate + end + + # @param [String] string, not bin + def update(message) + @blake2b.update(message) + @blake2b + end + + alias << update + + def digest + @blake2b.digest + end + + def self.generate(_opts = {}) + ::RbNaCl::Hash::Blake2b.new(DEFAULT_OPTIONS.dup) + end + + def self.digest(message) + ::RbNaCl::Hash::Blake2b.digest(message, DEFAULT_OPTIONS.dup) + end + end +end diff --git a/lib/ckb/rpc.rb b/lib/ckb/rpc.rb index 00450581..c9111a12 100644 --- a/lib/ckb/rpc.rb +++ b/lib/ckb/rpc.rb @@ -9,6 +9,8 @@ module CKB class RPC attr_reader :uri + attr_reader :system_script_out_point + attr_reader :system_script_cell_hash DEFAULT_URL = "http://localhost:8114" @@ -16,6 +18,20 @@ def initialize(host: DEFAULT_URL) @uri = URI(host) end + # @param out_point [Hash] { hash: "0x...", index: 0 } + # @param cell_hash [String] "0x..." + def set_system_script_cell(out_point, cell_hash) + @system_script_out_point = out_point + @system_script_cell_hash = cell_hash + end + + def system_script_cell + { + out_point: system_script_out_point, + cell_hash: system_script_cell_hash + } + end + def genesis_block @genesis_block ||= get_block(genesis_block_hash) end diff --git a/lib/ckb/utils.rb b/lib/ckb/utils.rb new file mode 100644 index 00000000..197469f6 --- /dev/null +++ b/lib/ckb/utils.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "secp256k1" + +module CKB + module Utils + def self.hex_to_bin(hex) + hex = hex[2..-1] if hex.start_with?("0x") + [hex].pack("H*") + end + + def self.bin_to_hex(bin) + bin.unpack1("H*") + end + + def self.bin_to_prefix_hex(bin) + "0x#{bin_to_hex(bin)}" + end + + def self.extract_pubkey_bin(privkey_bin) + Secp256k1::PrivateKey.new(privkey: privkey_bin).pubkey.serialize + end + + def self.json_script_to_type_hash(script) + blake2b = CKB::Blake2b.new + blake2b << hex_to_bin(script[:reference]) if script[:reference] + blake2b << "|" + blake2b << script[:binary] if script[:binary] + signed_args = script[:signed_args] || [] + signed_args.each do |arg| + blake2b << arg + end + bin_to_prefix_hex(blake2b.digest) + end + + def self.sign_sighash_all_inputs(inputs, outputs, privkey) + blake2b = CKB::Blake2b.new + sighash_type = 0x1.to_s + blake2b.update(sighash_type) + inputs.each do |input| + previous_output = input[:previous_output] + blake2b.update(hex_to_bin(previous_output[:hash])) + blake2b.update(previous_output[:index].to_s) + blake2b.update( + hex_to_bin( + json_script_to_type_hash(input[:unlock]) + ) + ) + end + outputs.each do |output| + blake2b.update(output[:capacity].to_s) + blake2b.update(hex_to_bin(output[:lock])) + next unless output[:type] + + blake2b.update( + hex_to_bin( + json_script_to_type_hash(output[:type]) + ) + ) + end + key = Secp256k1::PrivateKey.new(privkey: privkey) + signature_bin = key.ecdsa_serialize( + key.ecdsa_sign(blake2b.digest, raw: true) + ) + signature_hex = bin_to_hex(signature_bin) + + inputs.map do |input| + unlock = input[:unlock].merge(args: [signature_hex, sighash_type]) + input.merge(unlock: unlock) + end + end + + # In Ruby, bytes are represented using String, + # since JSON has no native byte arrays, + # CKB convention bytes passed with a "0x" prefix hex encoding, + # hence we have to do type conversions here. + def self.normalize_tx_for_json!(transaction) + transaction[:inputs].each do |input| + unlock = input[:unlock] + unlock[:args] = unlock[:args].map { |arg| bin_to_prefix_hex(arg) } + unlock[:signed_args] = unlock[:signed_args].map { |arg| bin_to_prefix_hex(arg) } + next unless unlock[:binary] + + unlock[:binary] = bin_to_prefix_hex(unlock[:binary]) + end + + transaction[:outputs].each do |output| + output[:data] = bin_to_prefix_hex(output[:data]) + next unless output[:type] + + type = output[:type] + type[:args] = type[:args].map { |arg| bin_to_prefix_hex(arg) } + type[:signed_args] = type[:signed_args].map { |arg| bin_to_prefix_hex(arg) } + type[:binary] = bin_to_prefix_hex(type[:binary]) if type[:binary] + end + + transaction + end + end +end diff --git a/lib/ckb/wallet.rb b/lib/ckb/wallet.rb new file mode 100644 index 00000000..614beffe --- /dev/null +++ b/lib/ckb/wallet.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +# rubocop:disable Naming/AccessorMethodName + +require "secp256k1" + +module CKB + MIN_CELL_CAPACITY = 40 + + class Wallet + attr_reader :rpc + # privkey is a bin string + attr_reader :privkey + + # @param rpc [CKB::RPC] + # @param privkey [String] bin string + def initialize(rpc, privkey) + raise ArgumentError, "invalid privkey!" unless privkey.instance_of?(String) && privkey.size == 32 + + @rpc = rpc + @privkey = privkey + end + + # @param rpc [CKB::RPC] + # @param privkey_hex [String] hex string + # + # @return [CKB::Wallet] + def self.from_hex(rpc, privkey_hex) + new(rpc, CKB::Utils.hex_to_bin(privkey_hex)) + end + + def address + verify_type_hash + end + + def get_unspent_cells + to = rpc.get_tip_block_number + results = [] + current_from = 1 + while current_from <= to + current_to = [current_from + 100, to].min + cells = rpc.get_cells_by_type_hash(address, current_from, current_to) + results.concat(cells) + current_from = current_to + 1 + end + results + end + + def get_balance + get_unspent_cells.map { |cell| cell[:capacity] }.reduce(0, &:+) + end + + def generate_tx(target_address, capacity) + i = gather_inputs(capacity, MIN_CELL_CAPACITY) + input_capacities = i.capacities + + outputs = [ + { + capacity: capacity, + data: "", + lock: target_address + } + ] + if input_capacities > capacity + outputs << { + capacity: input_capacities - capacity, + data: "", + lock: address + } + end + { + version: 0, + deps: [rpc.system_script_out_point], + inputs: CKB::Utils.sign_sighash_all_inputs(i.inputs, outputs, privkey), + outputs: outputs + } + end + + # @param target_address [String] "0x..." + # @param capacity [Integer] + def send_capacity(target_address, capacity) + tx = generate_tx(target_address, capacity) + send_transaction_bin(tx) + end + + # @param hash_hex [String] "0x..." + def get_transaction(hash_hex) + rpc.get_transaction(hash_hex) + end + + private + + def send_transaction_bin(transaction) + transaction = CKB::Utils.normalize_tx_for_json!(transaction) + rpc.send_transaction(transaction) + end + + def gather_inputs(capacity, min_capacity) + raise "capacity cannot be less than #{min_capacity}" if capacity < min_capacity + + input_capacities = 0 + inputs = [] + get_unspent_cells.each do |cell| + input = { + previous_output: cell[:out_point], + unlock: verify_script_json_object + } + inputs << input + input_capacities += cell[:capacity] + + break if input_capacities >= capacity && (input_capacities - capacity) >= min_capacity + end + + raise "Not enough capacity!" if input_capacities < capacity + + OpenStruct.new(inputs: inputs, capacities: input_capacities) + end + + def pubkey + CKB::Utils.bin_to_hex(pubkey_bin) + end + + def pubkey_bin + CKB::Utils.extract_pubkey_bin(privkey) + end + + def verify_script_json_object + { + version: 0, + reference: rpc.system_script_cell_hash, + signed_args: [ + # We could of course just hash raw bytes, but since right now CKB + # CLI already uses this scheme, we stick to the same way for compatibility + pubkey + ] + } + end + + def verify_type_hash + @verify_type_hash ||= CKB::Utils.json_script_to_type_hash(verify_script_json_object) + end + end +end + +# rubocop:enable Naming/AccessorMethodName diff --git a/spec/ckb/utils_spec.rb b/spec/ckb/utils_spec.rb new file mode 100644 index 00000000..ba9458f6 --- /dev/null +++ b/spec/ckb/utils_spec.rb @@ -0,0 +1,60 @@ +RSpec.describe CKB::RPC do + Utils = CKB::Utils + + let(:privkey) { "0xe79f3207ea4980b7fed79956d5934249ceac4751a4fae01a0f7c4a96884bc4e3" } + let(:pubkey) { "0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ead3d901330bc01" } + let(:address) { "0xbc374983430db3686ab181138bb510cb8f83aa136d833ac18fc3e73a3ad54b8b" } + let(:privkey_bin) { Utils.hex_to_bin(privkey) } + let(:pubkey_bin) { Utils.hex_to_bin(pubkey) } + + def always_success_json_object + hash_bin = CKB::Blake2b.digest( + Utils.hex_to_bin( + "0x1400000000000e00100000000c000800000004000e0000000c00000014000000740100000000000000000600080004000600000004000000580100007f454c460201010000000000000000000200f3000100000078000100000000004000000000000000980000000000000005000000400038000100400003000200010000000500000000000000000000000000010000000000000001000000000082000000000000008200000000000000001000000000000001459308d00573000000002e7368737472746162002e74657874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b000000010000000600000000000000780001000000000078000000000000000a000000000000000000000000000000020000000000000000000000000000000100000003000000000000000000000000000000000000008200000000000000110000000000000000000000000000000100000000000000000000000000000000000000" + ) + ) + always_success_cell_hash = Utils.bin_to_prefix_hex(hash_bin) + { + version: 0, + reference: always_success_cell_hash, + signed_args: [], + args: [] + } + end + + let(:always_success_type_hash) { "0x8954a4ac5e5c33eb7aa8bb91e0a000179708157729859bd8cf7e2278e1e12980" } + + it "hex to bin" do + hex = "abcd12" + expect(Utils.hex_to_bin(hex)).to eq [hex].pack("H*") + end + + it "prefix hex to bin" do + hex = "abcd12" + prefix_hex = "0x#{hex}" + expect(Utils.hex_to_bin(prefix_hex)).to eq Utils.hex_to_bin(hex) + end + + it "bin to hex" do + hex = "abcd12" + bin = [hex].pack("H*") + expect(Utils.bin_to_hex(bin)).to eq hex + end + + it "bin to prefix hex" do + hex = "abcd12" + bin = [hex].pack("H*") + prefix_hex = "0x#{hex}" + expect(Utils.bin_to_prefix_hex(bin)).to eq prefix_hex + end + + it "extract pubkey bin" do + expect(Utils.extract_pubkey_bin(privkey_bin)).to eq pubkey_bin + end + + it "json script to type hash" do + expect( + Utils.json_script_to_type_hash(always_success_json_object) + ).to eq always_success_type_hash + end +end