diff --git a/lib/bitcoin/descriptor.rb b/lib/bitcoin/descriptor.rb index b4ee9b6..bea1444 100644 --- a/lib/bitcoin/descriptor.rb +++ b/lib/bitcoin/descriptor.rb @@ -17,6 +17,8 @@ module Descriptor autoload :Raw, 'bitcoin/descriptor/raw' autoload :Addr, 'bitcoin/descriptor/addr' autoload :Tr, 'bitcoin/descriptor/tr' + autoload :MultiA, 'bitcoin/descriptor/multi_a' + autoload :SortedMultiA, 'bitcoin/descriptor/sorted_multi_a' autoload :Checksum, 'bitcoin/descriptor/checksum' module_function @@ -102,10 +104,26 @@ def tr(key, tree = nil) Tr.new(key, tree) end + # Generate tapscript multisig output for given keys. + # @param [Integer] threshold the threshold of multisig. + # @param [Array[String]] keys an array of keys. + # @return [Bitcoin::Descriptor::MultiA] multisig script. + def multi_a(threshold, *keys) + MultiA.new(threshold, keys) + end + + # Generate tapscript sorted multisig output for given keys. + # @param [Integer] threshold the threshold of multisig. + # @param [Array[String]] keys an array of keys. + # @return [Bitcoin::Descriptor::SortedMulti] + def sortedmulti_a(threshold, *keys) + SortedMultiA.new(threshold, keys) + end + # Parse descriptor string. # @param [String] string Descriptor string. # @return [Bitcoin::Descriptor::Expression] - def parse(string) + def parse(string, top_level = true) validate_checksum!(string) content, _ = string.split('#') exp, args_str = content.match(/(\w+)\((.+)\)/).captures @@ -117,23 +135,40 @@ def parse(string) when 'wpkh' wpkh(args_str) when 'sh' - sh(parse(args_str)) + sh(parse(args_str, false)) when 'wsh' - wsh(parse(args_str)) + wsh(parse(args_str, false)) when 'combo' combo(args_str) - when 'multi', 'sortedmulti' + when 'multi', 'sortedmulti', 'multi_a', 'sortedmulti_a' args = args_str.split(',') threshold = args[0].to_i keys = args[1..-1] - exp == 'multi' ? multi(threshold, *keys) : sortedmulti(threshold, *keys) + case exp + when 'multi' + multi(threshold, *keys) + when 'sortedmulti' + sortedmulti(threshold, *keys) + when 'multi_a' + raise ArgumentError, "Can only have multi_a/sortedmulti_a inside tr()." if top_level + multi_a(threshold, *keys) + when 'sortedmulti_a' + raise ArgumentError, "Can only have multi_a/sortedmulti_a inside tr()." if top_level + sortedmulti_a(threshold, *keys) + end when 'raw' raw(args_str) when 'addr' addr(args_str) when 'tr' key, rest = args_str.split(',', 2) - tr(key, parse_nested_string(rest)) + if rest.nil? + tr(key) + elsif rest.start_with?('{') + tr(key, parse_nested_string(rest)) + else + tr(key, parse(rest, false)) + end else raise ArgumentError, "Parse failed: #{string}" end @@ -166,7 +201,7 @@ def parse_nested_string(string) current = [] when '}' unless buffer.empty? - current << parse(buffer) + current << parse(buffer, false) buffer = "" end nested = current @@ -174,14 +209,14 @@ def parse_nested_string(string) current << nested when ',' unless buffer.empty? - current << parse(buffer) + current << parse(buffer, false) buffer = "" end else buffer << c end end - current << parse(buffer) unless buffer.empty? + current << parse(buffer, false) unless buffer.empty? current.first end end diff --git a/lib/bitcoin/descriptor/multi.rb b/lib/bitcoin/descriptor/multi.rb index e1b7367..039af88 100644 --- a/lib/bitcoin/descriptor/multi.rb +++ b/lib/bitcoin/descriptor/multi.rb @@ -22,11 +22,9 @@ def to_script def to_hex result = to_script - if result.multisig? - pubkey_count = result.get_pubkeys.length - raise RuntimeError, "Cannot have #{pubkey_count} pubkeys in bare multisig; only at most 3 pubkeys." if pubkey_count > 3 - end - super + pubkey_count = result.get_pubkeys.length + raise RuntimeError, "Cannot have #{pubkey_count} pubkeys in bare multisig; only at most 3 pubkeys." if pubkey_count > 3 + result.to_hex end def args diff --git a/lib/bitcoin/descriptor/multi_a.rb b/lib/bitcoin/descriptor/multi_a.rb new file mode 100644 index 0000000..d600009 --- /dev/null +++ b/lib/bitcoin/descriptor/multi_a.rb @@ -0,0 +1,43 @@ +module Bitcoin + module Descriptor + # multi_a() expression + # @see https://github.com/bitcoin/bips/blob/master/bip-0387.mediawiki + class MultiA < Multi + include Bitcoin::Opcodes + + def type + :multi_a + end + + def to_hex + raise RuntimeError, "Can only have multi_a/sortedmulti_a inside tr()." + end + + def to_script + multisig_script(keys.map{|k| extract_pubkey(k).xonly_pubkey}) + end + + private + + def multisig_script(keys) + script = Bitcoin::Script.new + keys.each.with_index do |k, i| + script << k + script << (i == 0 ? OP_CHECKSIG : OP_CHECKSIGADD) + end + script << threshold << OP_NUMEQUAL + end + + def validate!(threshold, keys) + raise ArgumentError, "Multisig threshold '#{threshold}' is not valid." unless threshold.is_a?(Integer) + raise ArgumentError, 'Multisig threshold cannot be 0, must be at least 1.' unless threshold > 0 + raise ArgumentError, 'Multisig threshold cannot be larger than the number of keys.' if threshold > keys.size + raise ArgumentError, "Multisig must have between 1 and 999 keys, inclusive." if keys.size > 999 + keys.each do |key| + k = extract_pubkey(key) + raise ArgumentError, "Uncompressed key are not allowed." unless k.compressed? + end + end + end + end +end \ No newline at end of file diff --git a/lib/bitcoin/descriptor/script_expression.rb b/lib/bitcoin/descriptor/script_expression.rb index f3bbeb1..7091c02 100644 --- a/lib/bitcoin/descriptor/script_expression.rb +++ b/lib/bitcoin/descriptor/script_expression.rb @@ -17,6 +17,7 @@ def args def validate!(script) raise ArgumentError, "Can only have #{script.type.to_s}() at top level." if script.is_a?(Expression) && script.top_level? + raise ArgumentError, 'Can only have multi_a/sortedmulti_a inside tr().' if script.is_a?(MultiA) || script.is_a?(SortedMultiA) end end end diff --git a/lib/bitcoin/descriptor/sorted_multi_a.rb b/lib/bitcoin/descriptor/sorted_multi_a.rb new file mode 100644 index 0000000..c708036 --- /dev/null +++ b/lib/bitcoin/descriptor/sorted_multi_a.rb @@ -0,0 +1,15 @@ +module Bitcoin + module Descriptor + # sortedmulti_a expression + # @see https://github.com/bitcoin/bips/blob/master/bip-0387.mediawiki + class SortedMultiA < MultiA + def type + :sortedmulti_a + end + + def to_script + multisig_script( keys.map{|k| extract_pubkey(k).xonly_pubkey}.sort) + end + end + end +end \ No newline at end of file diff --git a/lib/bitcoin/descriptor/tr.rb b/lib/bitcoin/descriptor/tr.rb index d943db2..484c4be 100644 --- a/lib/bitcoin/descriptor/tr.rb +++ b/lib/bitcoin/descriptor/tr.rb @@ -26,7 +26,11 @@ def top_level? end def args - tree.nil? ? key : "#{key},#{tree_string(tree)}" + if tree.nil? + key + else + tree.is_a?(Array) ? "#{key},#{tree_string(tree)}" : "#{key},#{tree}" + end end def to_script @@ -40,7 +44,7 @@ def build_tree_scripts internal_key = extract_pubkey(key) return Bitcoin::Taproot::SimpleBuilder.new(internal_key.xonly_pubkey) if tree.nil? if tree.is_a?(Expression) - tree.xonly = true + tree.xonly = true if tree.respond_to?(:xonly) Bitcoin::Taproot::SimpleBuilder.new(internal_key.xonly_pubkey, [Bitcoin::Taproot::LeafNode.new(tree.to_script)]) elsif tree.is_a?(Array) Bitcoin::Taproot::CustomDepthBuilder.new(internal_key.xonly_pubkey, parse_tree_items(tree)) diff --git a/spec/bitcoin/descriptor_spec.rb b/spec/bitcoin/descriptor_spec.rb index 333833b..64e5040 100644 --- a/spec/bitcoin/descriptor_spec.rb +++ b/spec/bitcoin/descriptor_spec.rb @@ -352,4 +352,57 @@ to raise_error(ArgumentError, "Can only have tr() at top level.") end end + + describe "BIP387" do + it do + expect(tr('L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + multi_a(1, 'KzoAz5CanayRKex3fSLQ2BwJpN7U52gZvxMyk78nDMHuqrUxuSJy')).to_hex). + to eq('5120eb5bd3894327d75093891cc3a62506df7d58ec137fcd104cdd285d67816074f3') + expect(tr('a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd', + multi_a(1, '669b8afcec803a0d323e9a17f3ea8e68e8abe5a278020a929adbec52421adbd0')).to_hex). + to eq('5120eb5bd3894327d75093891cc3a62506df7d58ec137fcd104cdd285d67816074f3') + key = '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0' + multi_keys = %w[[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0] + expect(tr(key, multi_a(2, *multi_keys)).to_hex).to eq('51202eea93581594a43c0c8423b70dc112e5651df63984d108d4fc8ccd3b63b4eafa') + expect(tr(key, sortedmulti_a(2, *multi_keys)).to_hex).to eq('512016fa6a6ba7e98c54b5bf43b3144912b78a61b60b02f6a74172b8dcb35b12bc30') + expect(tr(key, sortedmulti_a(2, + "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/0", + "xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0/0/0") + ).to_hex).to eq('5120abd47468515223f58a1a18edfde709a7a2aab2b696d59ecf8c34f0ba274ef772') + desc_multi_a = "tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,multi_a(2,xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U/2147483647'/0,xprv9vHkqa6EV4sPZHYqZznhT2NPtPCjKuDKGY38FBWLvgaDx45zo9WQRUT3dKYnjwih2yJD9mkrocEZXo1ex8G81dwSM1fwqWpWkeS3v86pgKt/1/2/0,xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi/10/20/30/40/0'))" + multi_a = described_class.parse(desc_multi_a) + expect(multi_a.to_hex).to eq('5120e4c8f2b0a7d3a688ac131cb03248c0d4b0a59bbd4f37211c848cfbd22a981192') + expect(multi_a.to_s).to eq(desc_multi_a) + + key = '03669b8afcec803a0d323e9a17f3ea8e68e8abe5a278020a929adbec52421adbd0' + expect{multi_a(1, key).to_hex}. + to raise_error(RuntimeError, 'Can only have multi_a/sortedmulti_a inside tr().') + expect{described_class.parse("multi_a(1,#{key})")}. + to raise_error(ArgumentError, 'Can only have multi_a/sortedmulti_a inside tr().') + expect{sortedmulti_a(1, key).to_hex}. + to raise_error(RuntimeError, 'Can only have multi_a/sortedmulti_a inside tr().') + expect{described_class.parse("sortedmulti_a(1,#{key})")}. + to raise_error(ArgumentError, 'Can only have multi_a/sortedmulti_a inside tr().') + expect{sh(multi_a(1, key))}.to raise_error(ArgumentError, 'Can only have multi_a/sortedmulti_a inside tr().') + expect{sh(sortedmulti_a(1, key))}.to raise_error(ArgumentError, 'Can only have multi_a/sortedmulti_a inside tr().') + expect{wsh(multi_a(1, key))}.to raise_error(ArgumentError, 'Can only have multi_a/sortedmulti_a inside tr().') + expect{wsh(sortedmulti_a(1, key))}.to raise_error(ArgumentError, 'Can only have multi_a/sortedmulti_a inside tr().') + key = '03a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd' + expect{multi_a('a', key)}.to raise_error(ArgumentError, "Multisig threshold 'a' is not valid.") + expect{sortedmulti_a('a', key)}.to raise_error(ArgumentError, "Multisig threshold 'a' is not valid.") + expect{multi_a(0, key)}.to raise_error(ArgumentError, "Multisig threshold cannot be 0, must be at least 1.") + expect{sortedmulti_a(0, key)}.to raise_error(ArgumentError, "Multisig threshold cannot be 0, must be at least 1.") + uncompressed_key = '04a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd5b8dec5235a0fa8722476c7709c02559e3aa73aa03918ba2d492eea75abea235' + expect{multi_a(1, uncompressed_key)}.to raise_error(ArgumentError, "Uncompressed key are not allowed.") + expect{sortedmulti_a(1, uncompressed_key)}.to raise_error(ArgumentError, "Uncompressed key are not allowed.") + expect{multi_a(3, + 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + '5KYZdUEo39z3FPrtuX2QbbwGnNP5zTd7yyr2SC1j299sBCnWjss')} + .to raise_error(ArgumentError, "Multisig threshold cannot be larger than the number of keys.") + expect{sortedmulti_a(3, + 'L4rK1yDtCWekvXuE6oXD9jCYfFNV2cWRpVuPLBcCU2z8TrisoyY1', + '5KYZdUEo39z3FPrtuX2QbbwGnNP5zTd7yyr2SC1j299sBCnWjss')} + .to raise_error(ArgumentError, "Multisig threshold cannot be larger than the number of keys.") + end + end end