bsv-sdk 0.2.1 → 0.3.0
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/lib/bsv/network/broadcast_response.rb +1 -2
- data/lib/bsv/primitives/bsm.rb +2 -6
- data/lib/bsv/primitives/curve.rb +1 -2
- data/lib/bsv/primitives/encrypted_message.rb +100 -0
- data/lib/bsv/primitives/extended_key.rb +1 -2
- data/lib/bsv/primitives/key_shares.rb +83 -0
- data/lib/bsv/primitives/mnemonic.rb +1 -3
- data/lib/bsv/primitives/point_in_finite_field.rb +72 -0
- data/lib/bsv/primitives/polynomial.rb +95 -0
- data/lib/bsv/primitives/private_key.rb +100 -3
- data/lib/bsv/primitives/signed_message.rb +104 -0
- data/lib/bsv/primitives/symmetric_key.rb +128 -0
- data/lib/bsv/primitives.rb +18 -12
- data/lib/bsv/script/interpreter/interpreter.rb +1 -3
- data/lib/bsv/script/interpreter/operations/bitwise.rb +1 -3
- data/lib/bsv/script/interpreter/operations/crypto.rb +3 -9
- data/lib/bsv/script/interpreter/operations/flow_control.rb +2 -6
- data/lib/bsv/script/interpreter/operations/splice.rb +1 -3
- data/lib/bsv/script/interpreter/script_number.rb +2 -7
- data/lib/bsv/script/script.rb +252 -1
- data/lib/bsv/transaction/beef.rb +1 -4
- data/lib/bsv/transaction/transaction.rb +123 -45
- data/lib/bsv/transaction/transaction_input.rb +1 -2
- data/lib/bsv/transaction/transaction_output.rb +1 -2
- data/lib/bsv/transaction/var_int.rb +4 -16
- data/lib/bsv/transaction.rb +14 -14
- data/lib/bsv/version.rb +1 -1
- data/lib/bsv/wallet_interface/errors/invalid_hmac_error.rb +11 -0
- data/lib/bsv/wallet_interface/errors/invalid_parameter_error.rb +14 -0
- data/lib/bsv/wallet_interface/errors/invalid_signature_error.rb +11 -0
- data/lib/bsv/wallet_interface/errors/unsupported_action_error.rb +11 -0
- data/lib/bsv/wallet_interface/errors/wallet_error.rb +14 -0
- data/lib/bsv/wallet_interface/interface.rb +384 -0
- data/lib/bsv/wallet_interface/key_deriver.rb +142 -0
- data/lib/bsv/wallet_interface/memory_store.rb +115 -0
- data/lib/bsv/wallet_interface/proto_wallet.rb +361 -0
- data/lib/bsv/wallet_interface/storage_adapter.rb +51 -0
- data/lib/bsv/wallet_interface/validators.rb +126 -0
- data/lib/bsv/wallet_interface/version.rb +7 -0
- data/lib/bsv/wallet_interface/wallet_client.rb +486 -0
- data/lib/bsv/wallet_interface.rb +25 -0
- data/lib/bsv-wallet.rb +4 -0
- metadata +24 -3
- /data/{LICENCE → LICENSE} +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module BSV
|
|
7
|
+
module Primitives
|
|
8
|
+
# AES-256-GCM symmetric encryption.
|
|
9
|
+
#
|
|
10
|
+
# Provides authenticated encryption matching the interface used by the
|
|
11
|
+
# TS, Go, and Python reference SDKs. The wire format is:
|
|
12
|
+
#
|
|
13
|
+
# |--- 32-byte IV ---|--- ciphertext ---|--- 16-byte auth tag ---|
|
|
14
|
+
#
|
|
15
|
+
# All three reference SDKs use a 32-byte IV (non-standard but
|
|
16
|
+
# cross-SDK compatible) and 16-byte authentication tag.
|
|
17
|
+
#
|
|
18
|
+
# @example Round-trip encryption
|
|
19
|
+
# key = BSV::Primitives::SymmetricKey.from_random
|
|
20
|
+
# encrypted = key.encrypt('hello world')
|
|
21
|
+
# key.decrypt(encrypted) #=> "hello world"
|
|
22
|
+
class SymmetricKey
|
|
23
|
+
IV_SIZE = 32
|
|
24
|
+
TAG_SIZE = 16
|
|
25
|
+
KEY_SIZE = 32
|
|
26
|
+
|
|
27
|
+
# @param key_bytes [String] 32-byte binary key (shorter keys are left-zero-padded)
|
|
28
|
+
# @raise [ArgumentError] if key is empty or longer than 32 bytes
|
|
29
|
+
def initialize(key_bytes)
|
|
30
|
+
key_bytes = key_bytes.b
|
|
31
|
+
raise ArgumentError, 'key must not be empty' if key_bytes.empty?
|
|
32
|
+
raise ArgumentError, "key must be at most #{KEY_SIZE} bytes, got #{key_bytes.bytesize}" if key_bytes.bytesize > KEY_SIZE
|
|
33
|
+
|
|
34
|
+
@key = if key_bytes.bytesize < KEY_SIZE
|
|
35
|
+
("\x00".b * (KEY_SIZE - key_bytes.bytesize)) + key_bytes
|
|
36
|
+
else
|
|
37
|
+
key_bytes
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Generate a random symmetric key.
|
|
42
|
+
#
|
|
43
|
+
# @return [SymmetricKey]
|
|
44
|
+
def self.from_random
|
|
45
|
+
new(SecureRandom.random_bytes(KEY_SIZE))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Derive a symmetric key from an ECDH shared secret.
|
|
49
|
+
#
|
|
50
|
+
# Computes the shared point between the two parties and uses the
|
|
51
|
+
# X-coordinate as the key material. The X-coordinate may be 31 or
|
|
52
|
+
# 32 bytes; shorter values are left-zero-padded automatically.
|
|
53
|
+
#
|
|
54
|
+
# @example Alice and Bob derive the same key
|
|
55
|
+
# alice_key = SymmetricKey.from_ecdh(alice_priv, bob_pub)
|
|
56
|
+
# bob_key = SymmetricKey.from_ecdh(bob_priv, alice_pub)
|
|
57
|
+
# alice_key.to_bytes == bob_key.to_bytes #=> true
|
|
58
|
+
#
|
|
59
|
+
# @param private_key [PrivateKey] one party's private key
|
|
60
|
+
# @param public_key [PublicKey] the other party's public key
|
|
61
|
+
# @return [SymmetricKey]
|
|
62
|
+
def self.from_ecdh(private_key, public_key)
|
|
63
|
+
shared = private_key.derive_shared_secret(public_key)
|
|
64
|
+
# X-coordinate = bytes 1..32 of the compressed point (skip the 02/03 prefix)
|
|
65
|
+
x_bytes = shared.compressed.byteslice(1, 32)
|
|
66
|
+
new(x_bytes)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Encrypt a message with AES-256-GCM.
|
|
70
|
+
#
|
|
71
|
+
# Generates a random 32-byte IV per call. Returns the concatenation
|
|
72
|
+
# of IV, ciphertext, and 16-byte authentication tag.
|
|
73
|
+
#
|
|
74
|
+
# @param plaintext [String] the message to encrypt
|
|
75
|
+
# @return [String] binary string: IV (32) + ciphertext + auth tag (16)
|
|
76
|
+
def encrypt(plaintext)
|
|
77
|
+
iv = SecureRandom.random_bytes(IV_SIZE)
|
|
78
|
+
|
|
79
|
+
cipher = OpenSSL::Cipher.new('aes-256-gcm')
|
|
80
|
+
cipher.encrypt
|
|
81
|
+
cipher.key = @key
|
|
82
|
+
cipher.iv_len = IV_SIZE
|
|
83
|
+
cipher.iv = iv
|
|
84
|
+
cipher.auth_data = ''.b
|
|
85
|
+
|
|
86
|
+
plaintext_bytes = plaintext.b
|
|
87
|
+
ciphertext = plaintext_bytes.empty? ? cipher.final : cipher.update(plaintext_bytes) + cipher.final
|
|
88
|
+
tag = cipher.auth_tag(TAG_SIZE)
|
|
89
|
+
|
|
90
|
+
iv + ciphertext + tag
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Decrypt an AES-256-GCM encrypted message.
|
|
94
|
+
#
|
|
95
|
+
# Expects the wire format: IV (32) + ciphertext + auth tag (16).
|
|
96
|
+
#
|
|
97
|
+
# @param data [String] the encrypted message
|
|
98
|
+
# @return [String] the decrypted plaintext (binary)
|
|
99
|
+
# @raise [ArgumentError] if the data is too short
|
|
100
|
+
# @raise [OpenSSL::Cipher::CipherError] if authentication fails (wrong key or tampered data)
|
|
101
|
+
def decrypt(data)
|
|
102
|
+
data = data.b
|
|
103
|
+
raise ArgumentError, "ciphertext too short: #{data.bytesize} bytes (minimum #{IV_SIZE + TAG_SIZE})" if data.bytesize < IV_SIZE + TAG_SIZE
|
|
104
|
+
|
|
105
|
+
iv = data.byteslice(0, IV_SIZE)
|
|
106
|
+
tag = data.byteslice(-TAG_SIZE, TAG_SIZE)
|
|
107
|
+
ciphertext = data.byteslice(IV_SIZE, data.bytesize - IV_SIZE - TAG_SIZE)
|
|
108
|
+
|
|
109
|
+
decipher = OpenSSL::Cipher.new('aes-256-gcm')
|
|
110
|
+
decipher.decrypt
|
|
111
|
+
decipher.key = @key
|
|
112
|
+
decipher.iv_len = IV_SIZE
|
|
113
|
+
decipher.iv = iv
|
|
114
|
+
decipher.auth_tag = tag
|
|
115
|
+
decipher.auth_data = ''.b
|
|
116
|
+
|
|
117
|
+
ciphertext.empty? ? decipher.final : decipher.update(ciphertext) + decipher.final
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Return the raw key bytes.
|
|
121
|
+
#
|
|
122
|
+
# @return [String] 32-byte binary key
|
|
123
|
+
def to_bytes
|
|
124
|
+
@key.dup
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/bsv/primitives.rb
CHANGED
|
@@ -7,17 +7,23 @@ module BSV
|
|
|
7
7
|
# HD key derivation (BIP-32), and mnemonic phrase generation (BIP-39).
|
|
8
8
|
# All cryptography uses Ruby's stdlib +openssl+ — no external gems.
|
|
9
9
|
module Primitives
|
|
10
|
-
autoload :Curve,
|
|
11
|
-
autoload :Digest,
|
|
12
|
-
autoload :Base58,
|
|
13
|
-
autoload :Signature,
|
|
14
|
-
autoload :ECDSA,
|
|
15
|
-
autoload :ECIES,
|
|
16
|
-
autoload :BSM,
|
|
17
|
-
autoload :Schnorr,
|
|
18
|
-
autoload :PublicKey,
|
|
19
|
-
autoload :PrivateKey,
|
|
20
|
-
autoload :ExtendedKey,
|
|
21
|
-
autoload :Mnemonic,
|
|
10
|
+
autoload :Curve, 'bsv/primitives/curve'
|
|
11
|
+
autoload :Digest, 'bsv/primitives/digest'
|
|
12
|
+
autoload :Base58, 'bsv/primitives/base58'
|
|
13
|
+
autoload :Signature, 'bsv/primitives/signature'
|
|
14
|
+
autoload :ECDSA, 'bsv/primitives/ecdsa'
|
|
15
|
+
autoload :ECIES, 'bsv/primitives/ecies'
|
|
16
|
+
autoload :BSM, 'bsv/primitives/bsm'
|
|
17
|
+
autoload :Schnorr, 'bsv/primitives/schnorr'
|
|
18
|
+
autoload :PublicKey, 'bsv/primitives/public_key'
|
|
19
|
+
autoload :PrivateKey, 'bsv/primitives/private_key'
|
|
20
|
+
autoload :ExtendedKey, 'bsv/primitives/extended_key'
|
|
21
|
+
autoload :Mnemonic, 'bsv/primitives/mnemonic'
|
|
22
|
+
autoload :SymmetricKey, 'bsv/primitives/symmetric_key'
|
|
23
|
+
autoload :SignedMessage, 'bsv/primitives/signed_message'
|
|
24
|
+
autoload :EncryptedMessage, 'bsv/primitives/encrypted_message'
|
|
25
|
+
autoload :PointInFiniteField, 'bsv/primitives/point_in_finite_field'
|
|
26
|
+
autoload :Polynomial, 'bsv/primitives/polynomial'
|
|
27
|
+
autoload :KeyShares, 'bsv/primitives/key_shares'
|
|
22
28
|
end
|
|
23
29
|
end
|
|
@@ -96,9 +96,7 @@ module BSV
|
|
|
96
96
|
break if @early_return && script_idx == 1
|
|
97
97
|
|
|
98
98
|
# Between scripts: verify conditionals balanced
|
|
99
|
-
unless @cond_stack.empty?
|
|
100
|
-
raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'unbalanced conditional')
|
|
101
|
-
end
|
|
99
|
+
raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'unbalanced conditional') unless @cond_stack.empty?
|
|
102
100
|
|
|
103
101
|
# Clear alt stack between scripts
|
|
104
102
|
@astack.clear
|
|
@@ -55,9 +55,7 @@ module BSV
|
|
|
55
55
|
def pop_equal_length_pair
|
|
56
56
|
a = @dstack.pop_bytes
|
|
57
57
|
b = @dstack.pop_bytes
|
|
58
|
-
if a.bytesize != b.bytesize
|
|
59
|
-
raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'byte arrays are not the same length')
|
|
60
|
-
end
|
|
58
|
+
raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'byte arrays are not the same length') if a.bytesize != b.bytesize
|
|
61
59
|
|
|
62
60
|
[a, b]
|
|
63
61
|
end
|
|
@@ -52,9 +52,7 @@ module BSV
|
|
|
52
52
|
result = verify_checksig(full_sig, pubkey_bytes)
|
|
53
53
|
|
|
54
54
|
# NULLFAIL: non-empty signature that failed verification
|
|
55
|
-
unless result
|
|
56
|
-
raise ScriptError.new(ScriptErrorCode::SIG_NULLFAIL, 'non-empty signature failed verification')
|
|
57
|
-
end
|
|
55
|
+
raise ScriptError.new(ScriptErrorCode::SIG_NULLFAIL, 'non-empty signature failed verification') unless result
|
|
58
56
|
|
|
59
57
|
@dstack.push_bool(true)
|
|
60
58
|
end
|
|
@@ -85,18 +83,14 @@ module BSV
|
|
|
85
83
|
|
|
86
84
|
# Dummy element (off-by-one bug compatibility)
|
|
87
85
|
dummy = @dstack.pop_bytes
|
|
88
|
-
unless dummy.empty?
|
|
89
|
-
raise ScriptError.new(ScriptErrorCode::SIG_NULLDUMMY, 'CHECKMULTISIG dummy element must be empty')
|
|
90
|
-
end
|
|
86
|
+
raise ScriptError.new(ScriptErrorCode::SIG_NULLDUMMY, 'CHECKMULTISIG dummy element must be empty') unless dummy.empty?
|
|
91
87
|
|
|
92
88
|
success = multisig_match?(signatures, pubkeys)
|
|
93
89
|
|
|
94
90
|
# NULLFAIL: if failed, all signatures must be empty
|
|
95
91
|
unless success
|
|
96
92
|
signatures.each do |sig|
|
|
97
|
-
unless sig.empty?
|
|
98
|
-
raise ScriptError.new(ScriptErrorCode::SIG_NULLFAIL, 'non-empty signature failed verification')
|
|
99
|
-
end
|
|
93
|
+
raise ScriptError.new(ScriptErrorCode::SIG_NULLFAIL, 'non-empty signature failed verification') unless sig.empty?
|
|
100
94
|
end
|
|
101
95
|
end
|
|
102
96
|
|
|
@@ -30,9 +30,7 @@ module BSV
|
|
|
30
30
|
|
|
31
31
|
# OP_ELSE: toggle conditional branch (only one ELSE per IF after genesis)
|
|
32
32
|
def op_else
|
|
33
|
-
if @cond_stack.empty?
|
|
34
|
-
raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'OP_ELSE without matching OP_IF')
|
|
35
|
-
end
|
|
33
|
+
raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'OP_ELSE without matching OP_IF') if @cond_stack.empty?
|
|
36
34
|
|
|
37
35
|
# After genesis: only one ELSE per IF
|
|
38
36
|
raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'duplicate OP_ELSE') if @else_stack.pop
|
|
@@ -47,9 +45,7 @@ module BSV
|
|
|
47
45
|
|
|
48
46
|
# OP_ENDIF: close conditional block
|
|
49
47
|
def op_endif
|
|
50
|
-
if @cond_stack.empty?
|
|
51
|
-
raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'OP_ENDIF without matching OP_IF')
|
|
52
|
-
end
|
|
48
|
+
raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'OP_ENDIF without matching OP_IF') if @cond_stack.empty?
|
|
53
49
|
|
|
54
50
|
@cond_stack.pop
|
|
55
51
|
@else_stack.pop
|
|
@@ -44,9 +44,7 @@ module BSV
|
|
|
44
44
|
size = @dstack.pop_int.to_i32
|
|
45
45
|
data = @dstack.pop_bytes
|
|
46
46
|
|
|
47
|
-
if size.negative?
|
|
48
|
-
raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'OP_NUM2BIN: size is negative')
|
|
49
|
-
end
|
|
47
|
+
raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'OP_NUM2BIN: size is negative') if size.negative?
|
|
50
48
|
|
|
51
49
|
minimal = ScriptNumber.minimally_encode(data)
|
|
52
50
|
|
|
@@ -44,10 +44,7 @@ module BSV
|
|
|
44
44
|
return new(0) if bytes.empty?
|
|
45
45
|
|
|
46
46
|
if bytes.bytesize > max_length
|
|
47
|
-
raise ScriptError.new
|
|
48
|
-
ScriptErrorCode::NUMBER_TOO_BIG,
|
|
49
|
-
"script number overflow: #{bytes.bytesize} > #{max_length}"
|
|
50
|
-
)
|
|
47
|
+
raise ScriptError.new ScriptErrorCode::NUMBER_TOO_BIG, "script number overflow: #{bytes.bytesize} > #{max_length}"
|
|
51
48
|
end
|
|
52
49
|
|
|
53
50
|
check_minimal_encoding!(bytes) if require_minimal
|
|
@@ -202,9 +199,7 @@ module BSV
|
|
|
202
199
|
return if msb.anybits?(0x7f)
|
|
203
200
|
|
|
204
201
|
# Single byte that is pure sign/zero (0x00 or 0x80) — not minimal
|
|
205
|
-
if bytes.bytesize == 1
|
|
206
|
-
raise ScriptError.new(ScriptErrorCode::MINIMAL_DATA, 'non-minimal script number encoding')
|
|
207
|
-
end
|
|
202
|
+
raise ScriptError.new(ScriptErrorCode::MINIMAL_DATA, 'non-minimal script number encoding') if bytes.bytesize == 1
|
|
208
203
|
|
|
209
204
|
# Padding is justified if second-to-last byte has high bit set
|
|
210
205
|
return if bytes.getbyte(bytes.bytesize - 2) & 0x80 != 0
|
data/lib/bsv/script/script.rb
CHANGED
|
@@ -177,6 +177,100 @@ module BSV
|
|
|
177
177
|
new(buf)
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
+
# Construct a PushDrop locking script.
|
|
181
|
+
#
|
|
182
|
+
# Pushes arbitrary data fields onto the stack, then drops them all
|
|
183
|
+
# before the locking condition executes. Used for token protocols
|
|
184
|
+
# where data must be embedded in spendable outputs.
|
|
185
|
+
#
|
|
186
|
+
# Structure: +[field0] [field1] ... [fieldN] [OP_2DROP...] [OP_DROP?] [lock_script]+
|
|
187
|
+
#
|
|
188
|
+
# @param fields [Array<String>] data payloads to embed (binary strings)
|
|
189
|
+
# @param lock_script [Script] the underlying locking condition (e.g. P2PKH)
|
|
190
|
+
# @return [Script]
|
|
191
|
+
# @raise [ArgumentError] if fields is empty or lock_script is not a Script
|
|
192
|
+
def self.pushdrop_lock(fields, lock_script)
|
|
193
|
+
raise ArgumentError, 'fields must not be empty' if fields.empty?
|
|
194
|
+
raise ArgumentError, 'lock_script must be a Script' unless lock_script.is_a?(Script)
|
|
195
|
+
|
|
196
|
+
chunks = fields.map { |f| encode_minimally(f.b) }
|
|
197
|
+
|
|
198
|
+
remaining = fields.length
|
|
199
|
+
while remaining > 1
|
|
200
|
+
chunks << Chunk.new(opcode: Opcodes::OP_2DROP)
|
|
201
|
+
remaining -= 2
|
|
202
|
+
end
|
|
203
|
+
chunks << Chunk.new(opcode: Opcodes::OP_DROP) if remaining == 1
|
|
204
|
+
|
|
205
|
+
chunks.concat(lock_script.chunks)
|
|
206
|
+
from_chunks(chunks)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Construct a PushDrop unlocking script.
|
|
210
|
+
#
|
|
211
|
+
# Pass-through wrapper — the data fields are dropped during execution,
|
|
212
|
+
# so the unlocking script just needs to satisfy the underlying lock.
|
|
213
|
+
#
|
|
214
|
+
# @param unlock_script [Script] unlocking script for the underlying condition
|
|
215
|
+
# @return [Script]
|
|
216
|
+
def self.pushdrop_unlock(unlock_script)
|
|
217
|
+
unlock_script
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Hash type to opcode mapping for RPuzzle scripts.
|
|
221
|
+
RPUZZLE_HASH_OPS = {
|
|
222
|
+
raw: nil,
|
|
223
|
+
sha1: Opcodes::OP_SHA1,
|
|
224
|
+
ripemd160: Opcodes::OP_RIPEMD160,
|
|
225
|
+
sha256: Opcodes::OP_SHA256,
|
|
226
|
+
hash160: Opcodes::OP_HASH160,
|
|
227
|
+
hash256: Opcodes::OP_HASH256
|
|
228
|
+
}.freeze
|
|
229
|
+
|
|
230
|
+
# Reverse lookup: opcode → hash type symbol (excludes :raw).
|
|
231
|
+
RPUZZLE_OP_TO_TYPE = RPUZZLE_HASH_OPS.reject { |k, _| k == :raw }.invert.freeze
|
|
232
|
+
|
|
233
|
+
# The fixed opcode prefix shared by all RPuzzle locking scripts.
|
|
234
|
+
# OP_OVER OP_3 OP_SPLIT OP_NIP OP_1 OP_SPLIT OP_SWAP OP_SPLIT OP_DROP
|
|
235
|
+
RPUZZLE_PREFIX = [
|
|
236
|
+
Opcodes::OP_OVER, Opcodes::OP_3, Opcodes::OP_SPLIT,
|
|
237
|
+
Opcodes::OP_NIP, Opcodes::OP_1, Opcodes::OP_SPLIT,
|
|
238
|
+
Opcodes::OP_SWAP, Opcodes::OP_SPLIT, Opcodes::OP_DROP
|
|
239
|
+
].freeze
|
|
240
|
+
|
|
241
|
+
# Construct an RPuzzle locking script.
|
|
242
|
+
#
|
|
243
|
+
# RPuzzle enables hash-puzzle-based spending where the spender proves
|
|
244
|
+
# knowledge of the ECDSA K-value (nonce) that produced a signature's
|
|
245
|
+
# R component.
|
|
246
|
+
#
|
|
247
|
+
# @param hash_value [String] the R-value or hash of R-value to lock against
|
|
248
|
+
# @param hash_type [Symbol] one of +:raw+, +:sha1+, +:ripemd160+,
|
|
249
|
+
# +:sha256+, +:hash160+, +:hash256+
|
|
250
|
+
# @return [Script]
|
|
251
|
+
# @raise [ArgumentError] if hash_type is invalid
|
|
252
|
+
def self.rpuzzle_lock(hash_value, hash_type: :hash160)
|
|
253
|
+
raise ArgumentError, "unknown hash_type: #{hash_type}" unless RPUZZLE_HASH_OPS.key?(hash_type)
|
|
254
|
+
|
|
255
|
+
buf = RPUZZLE_PREFIX.pack('C*')
|
|
256
|
+
hash_op = RPUZZLE_HASH_OPS[hash_type]
|
|
257
|
+
buf << [hash_op].pack('C') if hash_op
|
|
258
|
+
buf << encode_push_data(hash_value.b)
|
|
259
|
+
buf << [Opcodes::OP_EQUALVERIFY, Opcodes::OP_CHECKSIG].pack('CC')
|
|
260
|
+
new(buf)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Construct an RPuzzle unlocking script.
|
|
264
|
+
#
|
|
265
|
+
# Same wire format as P2PKH: signature + public key.
|
|
266
|
+
#
|
|
267
|
+
# @param signature_der [String] DER-encoded signature with sighash byte
|
|
268
|
+
# @param pubkey_bytes [String] compressed or uncompressed public key bytes
|
|
269
|
+
# @return [Script]
|
|
270
|
+
def self.rpuzzle_unlock(signature_der, pubkey_bytes)
|
|
271
|
+
p2pkh_unlock(signature_der, pubkey_bytes)
|
|
272
|
+
end
|
|
273
|
+
|
|
180
274
|
# --- Serialisation ---
|
|
181
275
|
|
|
182
276
|
# @return [String] a copy of the raw script bytes
|
|
@@ -256,6 +350,76 @@ module BSV
|
|
|
256
350
|
([0x04, 0x06, 0x07].include?(version) && pubkey.bytesize == 65)
|
|
257
351
|
end
|
|
258
352
|
|
|
353
|
+
# Whether this is a PushDrop script.
|
|
354
|
+
#
|
|
355
|
+
# Detects scripts with one or more data pushes followed by a
|
|
356
|
+
# OP_DROP/OP_2DROP chain and a recognisable locking condition.
|
|
357
|
+
#
|
|
358
|
+
# @return [Boolean]
|
|
359
|
+
def pushdrop?
|
|
360
|
+
c = chunks
|
|
361
|
+
return false if c.length < 3
|
|
362
|
+
|
|
363
|
+
# Find the first DROP/2DROP — everything before is data fields
|
|
364
|
+
drop_start = c.index { |ch| [Opcodes::OP_DROP, Opcodes::OP_2DROP].include?(ch.opcode) }
|
|
365
|
+
return false unless drop_start&.positive?
|
|
366
|
+
|
|
367
|
+
# All chunks before first drop must be data pushes or minimal push opcodes
|
|
368
|
+
field_chunks = c[0...drop_start]
|
|
369
|
+
return false unless field_chunks.all? { |ch| ch.data? || minimal_push_opcode?(ch.opcode) }
|
|
370
|
+
|
|
371
|
+
# Count fields and verify the drop sequence
|
|
372
|
+
num_fields = field_chunks.length
|
|
373
|
+
expected_drops = []
|
|
374
|
+
remaining = num_fields
|
|
375
|
+
while remaining > 1
|
|
376
|
+
expected_drops << Opcodes::OP_2DROP
|
|
377
|
+
remaining -= 2
|
|
378
|
+
end
|
|
379
|
+
expected_drops << Opcodes::OP_DROP if remaining == 1
|
|
380
|
+
|
|
381
|
+
drop_end = drop_start + expected_drops.length
|
|
382
|
+
return false if drop_end > c.length
|
|
383
|
+
|
|
384
|
+
actual_drops = c[drop_start...drop_end].map(&:opcode)
|
|
385
|
+
return false unless actual_drops == expected_drops
|
|
386
|
+
|
|
387
|
+
# Must have at least one chunk after the drops (the lock script)
|
|
388
|
+
drop_end < c.length
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Whether this is an RPuzzle script.
|
|
392
|
+
#
|
|
393
|
+
# Detects the fixed R-value extraction prefix followed by an optional
|
|
394
|
+
# hash opcode, a data push, OP_EQUALVERIFY, and OP_CHECKSIG.
|
|
395
|
+
#
|
|
396
|
+
# @return [Boolean]
|
|
397
|
+
def rpuzzle?
|
|
398
|
+
c = chunks
|
|
399
|
+
# Minimum: 9 prefix + hash_data + OP_EQUALVERIFY + OP_CHECKSIG = 12
|
|
400
|
+
# With hash op: 13
|
|
401
|
+
return false unless c.length >= 12
|
|
402
|
+
|
|
403
|
+
# Verify the 9-opcode prefix
|
|
404
|
+
RPUZZLE_PREFIX.each_with_index do |op, i|
|
|
405
|
+
return false unless c[i].opcode == op
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# After prefix: optional hash op, then data push, OP_EQUALVERIFY, OP_CHECKSIG
|
|
409
|
+
return false unless c[-1].opcode == Opcodes::OP_CHECKSIG
|
|
410
|
+
return false unless c[-2].opcode == Opcodes::OP_EQUALVERIFY
|
|
411
|
+
return false unless c[-3].data?
|
|
412
|
+
|
|
413
|
+
# Either exactly 12 chunks (raw) or 13 chunks (with hash op)
|
|
414
|
+
if c.length == 12
|
|
415
|
+
true
|
|
416
|
+
elsif c.length == 13
|
|
417
|
+
RPUZZLE_HASH_OPS.values.compact.include?(c[9].opcode)
|
|
418
|
+
else
|
|
419
|
+
false
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
259
423
|
# Whether this is a bare multisig script.
|
|
260
424
|
#
|
|
261
425
|
# Pattern: +OP_M <pubkey1> ... <pubkeyN> OP_N OP_CHECKMULTISIG+
|
|
@@ -275,7 +439,8 @@ module BSV
|
|
|
275
439
|
# Classify the script as a standard type.
|
|
276
440
|
#
|
|
277
441
|
# @return [String] one of +"empty"+, +"pubkeyhash"+, +"pubkey"+,
|
|
278
|
-
# +"scripthash"+, +"nulldata"+, +"multisig"+,
|
|
442
|
+
# +"scripthash"+, +"nulldata"+, +"multisig"+, +"pushdrop"+,
|
|
443
|
+
# +"rpuzzle"+, or +"nonstandard"+
|
|
279
444
|
def type
|
|
280
445
|
if @bytes.empty? then 'empty'
|
|
281
446
|
elsif p2pkh? then 'pubkeyhash'
|
|
@@ -283,6 +448,8 @@ module BSV
|
|
|
283
448
|
elsif p2sh? then 'scripthash'
|
|
284
449
|
elsif op_return? then 'nulldata'
|
|
285
450
|
elsif multisig? then 'multisig'
|
|
451
|
+
elsif pushdrop? then 'pushdrop'
|
|
452
|
+
elsif rpuzzle? then 'rpuzzle'
|
|
286
453
|
else 'nonstandard'
|
|
287
454
|
end
|
|
288
455
|
end
|
|
@@ -317,6 +484,49 @@ module BSV
|
|
|
317
484
|
Script.new(@bytes.byteslice(start..)).chunks.select(&:data?).map(&:data)
|
|
318
485
|
end
|
|
319
486
|
|
|
487
|
+
# Extract the hash value from an RPuzzle script.
|
|
488
|
+
#
|
|
489
|
+
# @return [String, nil] the locked hash/R-value, or +nil+ if not RPuzzle
|
|
490
|
+
def rpuzzle_hash
|
|
491
|
+
return unless rpuzzle?
|
|
492
|
+
|
|
493
|
+
chunks[-3].data
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Detect the hash type used in an RPuzzle script.
|
|
497
|
+
#
|
|
498
|
+
# @return [Symbol, nil] the hash type (e.g. +:hash160+, +:raw+), or +nil+ if not RPuzzle
|
|
499
|
+
def rpuzzle_hash_type
|
|
500
|
+
return unless rpuzzle?
|
|
501
|
+
|
|
502
|
+
chunks.length == 12 ? :raw : RPUZZLE_OP_TO_TYPE[chunks[9].opcode]
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Extract the embedded data fields from a PushDrop script.
|
|
506
|
+
#
|
|
507
|
+
# @return [Array<String>, nil] array of field data, or +nil+ if not PushDrop
|
|
508
|
+
def pushdrop_fields
|
|
509
|
+
return unless pushdrop?
|
|
510
|
+
|
|
511
|
+
c = chunks
|
|
512
|
+
drop_start = c.index { |ch| [Opcodes::OP_DROP, Opcodes::OP_2DROP].include?(ch.opcode) }
|
|
513
|
+
c[0...drop_start].map { |ch| decode_minimal_push(ch) }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Extract the underlying lock script from a PushDrop script.
|
|
517
|
+
#
|
|
518
|
+
# @return [Script, nil] the lock script portion, or +nil+ if not PushDrop
|
|
519
|
+
def pushdrop_lock_script
|
|
520
|
+
return unless pushdrop?
|
|
521
|
+
|
|
522
|
+
c = chunks
|
|
523
|
+
drop_start = c.index { |ch| [Opcodes::OP_DROP, Opcodes::OP_2DROP].include?(ch.opcode) }
|
|
524
|
+
num_fields = drop_start
|
|
525
|
+
num_drops = (num_fields / 2) + (num_fields.odd? ? 1 : 0)
|
|
526
|
+
lock_start = drop_start + num_drops
|
|
527
|
+
self.class.from_chunks(c[lock_start..])
|
|
528
|
+
end
|
|
529
|
+
|
|
320
530
|
# Derive Bitcoin addresses from this script.
|
|
321
531
|
#
|
|
322
532
|
# Currently supports P2PKH scripts only.
|
|
@@ -366,6 +576,26 @@ module BSV
|
|
|
366
576
|
end
|
|
367
577
|
end
|
|
368
578
|
|
|
579
|
+
def encode_minimally(data)
|
|
580
|
+
len = data.bytesize
|
|
581
|
+
|
|
582
|
+
if len.zero? || (len == 1 && data.getbyte(0).zero?)
|
|
583
|
+
Chunk.new(opcode: Opcodes::OP_0)
|
|
584
|
+
elsif len == 1 && data.getbyte(0).between?(1, 16)
|
|
585
|
+
Chunk.new(opcode: 0x50 + data.getbyte(0))
|
|
586
|
+
elsif len == 1 && data.getbyte(0) == 0x81
|
|
587
|
+
Chunk.new(opcode: Opcodes::OP_1NEGATE)
|
|
588
|
+
elsif len <= 0x4b
|
|
589
|
+
Chunk.new(opcode: len, data: data)
|
|
590
|
+
elsif len <= 0xff
|
|
591
|
+
Chunk.new(opcode: Opcodes::OP_PUSHDATA1, data: data)
|
|
592
|
+
elsif len <= 0xffff
|
|
593
|
+
Chunk.new(opcode: Opcodes::OP_PUSHDATA2, data: data)
|
|
594
|
+
else
|
|
595
|
+
Chunk.new(opcode: Opcodes::OP_PUSHDATA4, data: data)
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
369
599
|
def resolve_opcode(token)
|
|
370
600
|
return nil unless token.start_with?('OP_')
|
|
371
601
|
|
|
@@ -381,6 +611,23 @@ module BSV
|
|
|
381
611
|
opcode == Opcodes::OP_0 || opcode.between?(Opcodes::OP_1, Opcodes::OP_16)
|
|
382
612
|
end
|
|
383
613
|
|
|
614
|
+
def minimal_push_opcode?(opcode)
|
|
615
|
+
opcode == Opcodes::OP_0 ||
|
|
616
|
+
opcode == Opcodes::OP_1NEGATE ||
|
|
617
|
+
opcode.between?(Opcodes::OP_1, Opcodes::OP_16)
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def decode_minimal_push(chunk)
|
|
621
|
+
return chunk.data if chunk.data?
|
|
622
|
+
|
|
623
|
+
case chunk.opcode
|
|
624
|
+
when Opcodes::OP_0 then ''.b
|
|
625
|
+
when Opcodes::OP_1NEGATE then "\x81".b
|
|
626
|
+
when Opcodes::OP_1..Opcodes::OP_16
|
|
627
|
+
[chunk.opcode - 0x50].pack('C')
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
|
|
384
631
|
def parse_chunks
|
|
385
632
|
result = []
|
|
386
633
|
pos = 0
|
|
@@ -392,11 +639,13 @@ module BSV
|
|
|
392
639
|
|
|
393
640
|
if opcode.positive? && opcode <= 0x4b
|
|
394
641
|
raise ArgumentError, "truncated script: need #{opcode} data bytes at offset #{pos}" if pos + opcode > raw.bytesize
|
|
642
|
+
|
|
395
643
|
data = raw.byteslice(pos, opcode)
|
|
396
644
|
pos += opcode
|
|
397
645
|
result << Chunk.new(opcode: opcode, data: data)
|
|
398
646
|
elsif opcode == Opcodes::OP_PUSHDATA1
|
|
399
647
|
raise ArgumentError, "truncated script: OP_PUSHDATA1 missing length byte at offset #{pos}" if pos >= raw.bytesize
|
|
648
|
+
|
|
400
649
|
len = raw.getbyte(pos)
|
|
401
650
|
pos += 1
|
|
402
651
|
data = raw.byteslice(pos, len)
|
|
@@ -404,6 +653,7 @@ module BSV
|
|
|
404
653
|
result << Chunk.new(opcode: opcode, data: data)
|
|
405
654
|
elsif opcode == Opcodes::OP_PUSHDATA2
|
|
406
655
|
raise ArgumentError, "truncated script: OP_PUSHDATA2 needs 2 length bytes at offset #{pos}" if pos + 2 > raw.bytesize
|
|
656
|
+
|
|
407
657
|
len = raw.byteslice(pos, 2).unpack1('v')
|
|
408
658
|
pos += 2
|
|
409
659
|
data = raw.byteslice(pos, len)
|
|
@@ -411,6 +661,7 @@ module BSV
|
|
|
411
661
|
result << Chunk.new(opcode: opcode, data: data)
|
|
412
662
|
elsif opcode == Opcodes::OP_PUSHDATA4
|
|
413
663
|
raise ArgumentError, "truncated script: OP_PUSHDATA4 needs 4 length bytes at offset #{pos}" if pos + 4 > raw.bytesize
|
|
664
|
+
|
|
414
665
|
len = raw.byteslice(pos, 4).unpack1('V')
|
|
415
666
|
pos += 4
|
|
416
667
|
data = raw.byteslice(pos, len)
|
data/lib/bsv/transaction/beef.rb
CHANGED
|
@@ -106,10 +106,7 @@ module BSV
|
|
|
106
106
|
# @param data [String] raw BEEF binary
|
|
107
107
|
# @return [Beef] the parsed BEEF bundle
|
|
108
108
|
def self.from_binary(data)
|
|
109
|
-
if data.bytesize < 4
|
|
110
|
-
raise ArgumentError,
|
|
111
|
-
"truncated BEEF: need at least 4 bytes for version, got #{data.bytesize}"
|
|
112
|
-
end
|
|
109
|
+
raise ArgumentError, "truncated BEEF: need at least 4 bytes for version, got #{data.bytesize}" if data.bytesize < 4
|
|
113
110
|
|
|
114
111
|
offset = 0
|
|
115
112
|
|