bsv-sdk 0.4.0 → 0.5.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.
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Identity
5
+ # Parses an {IdentityCertificate} into a {DisplayableIdentity} suitable for
6
+ # presentation in a user interface.
7
+ #
8
+ # Handles all 9 well-known certificate types (xCert, discordCert, phoneCert,
9
+ # emailCert, identiCert, registrant, coolCert, anyone, self) with type-specific
10
+ # field extraction that matches the TS SDK implementation exactly. Unknown
11
+ # certificate types fall through to a generic field-name heuristic.
12
+ module IdentityParser
13
+ # Well-known avatar opaque strings used by specific certificate types.
14
+ EMAIL_AVATAR = 'XUTZxep7BBghAJbSBwTjNfmcsDdRFs5EaGEgkESGSgjJVYgMEizu'
15
+ PHONE_AVATAR = 'XUTLxtX3ELNUwRhLwL7kWNGbdnFM8WG2eSLv84J7654oH8HaJWrU'
16
+ ANYONE_AVATAR = 'XUT4bpQ6cpBaXi1oMzZsXfpkWGbtp2JTUYAoN7PzhStFJ6wLfoeR'
17
+ SELF_AVATAR = 'XUT9jHGk2qace148jeCX5rDsMftkSGYKmigLwU2PLLBc7Hm63VYR'
18
+ BADGE_ICON = 'XUUV39HVPkpmMzYNTx7rpKzJvXfeiVyQWg2vfSpjBAuhunTCA9uG'
19
+
20
+ # Parses an {IdentityCertificate} and returns a {DisplayableIdentity}.
21
+ #
22
+ # @param identity_certificate [IdentityCertificate]
23
+ # @return [DisplayableIdentity]
24
+ def self.parse(identity_certificate)
25
+ fields = identity_certificate.decrypted_fields
26
+ certifier = identity_certificate.certifier_info
27
+ type_b64 = identity_certificate.certificate[:type]
28
+
29
+ name, avatar_url, badge_label, badge_icon_url, badge_click_url =
30
+ extract_fields(type_b64, fields, certifier)
31
+
32
+ subject = identity_certificate.certificate[:subject].to_s
33
+ identity_key = subject
34
+ abbreviated = abbreviated_key(subject)
35
+
36
+ DisplayableIdentity.new(
37
+ name: name,
38
+ avatar_url: avatar_url,
39
+ abbreviated_key: abbreviated,
40
+ identity_key: identity_key,
41
+ badge_icon_url: badge_icon_url,
42
+ badge_label: badge_label,
43
+ badge_click_url: badge_click_url
44
+ )
45
+ end
46
+
47
+ # -----------------------------------------------------------------------
48
+ # Private helpers
49
+ # -----------------------------------------------------------------------
50
+
51
+ # Returns true when +val+ is a non-nil, non-empty string.
52
+ def self.present?(val)
53
+ val.is_a?(String) && !val.empty?
54
+ end
55
+ private_class_method :present?
56
+
57
+ # Returns the first 10 characters of the subject followed by '...',
58
+ # or the subject itself when it is shorter than 10 characters.
59
+ def self.abbreviated_key(subject)
60
+ return '' unless present?(subject)
61
+ return subject if subject.length < 10
62
+
63
+ "#{subject[0, 10]}..."
64
+ end
65
+ private_class_method :abbreviated_key
66
+
67
+ # Dispatches to per-type extraction logic and returns a 5-element array:
68
+ # [name, avatar_url, badge_label, badge_icon_url, badge_click_url]
69
+ def self.extract_fields(type_b64, fields, certifier)
70
+ types = Constants::KNOWN_IDENTITY_TYPES
71
+
72
+ case type_b64
73
+ when types[:x_cert] then parse_x_cert(fields, certifier)
74
+ when types[:discord_cert] then parse_discord_cert(fields, certifier)
75
+ when types[:email_cert] then parse_email_cert(fields, certifier)
76
+ when types[:phone_cert] then parse_phone_cert(fields, certifier)
77
+ when types[:identi_cert] then parse_identi_cert(fields, certifier)
78
+ when types[:registrant] then parse_registrant(fields, certifier)
79
+ when types[:cool_cert] then parse_cool_cert(fields)
80
+ when types[:anyone] then parse_anyone
81
+ when types[:self] then parse_self
82
+ else parse_generic(type_b64, fields, certifier)
83
+ end
84
+ end
85
+ private_class_method :extract_fields
86
+
87
+ # -- Known-type parsers --------------------------------------------------
88
+
89
+ def self.parse_x_cert(fields, certifier)
90
+ name = fields['userName']
91
+ avatar_url = fields['profilePhoto']
92
+ badge_label = "X account certified by #{certifier&.name}"
93
+ badge_icon = certifier&.icon_url
94
+ badge_click = 'https://socialcert.net'
95
+ [name, avatar_url, badge_label, badge_icon, badge_click]
96
+ end
97
+ private_class_method :parse_x_cert
98
+
99
+ def self.parse_discord_cert(fields, certifier)
100
+ name = fields['userName']
101
+ avatar_url = fields['profilePhoto']
102
+ badge_label = "Discord account certified by #{certifier&.name}"
103
+ badge_icon = certifier&.icon_url
104
+ badge_click = 'https://socialcert.net'
105
+ [name, avatar_url, badge_label, badge_icon, badge_click]
106
+ end
107
+ private_class_method :parse_discord_cert
108
+
109
+ def self.parse_email_cert(fields, certifier)
110
+ name = fields['email']
111
+ avatar_url = EMAIL_AVATAR
112
+ badge_label = "Email certified by #{certifier&.name}"
113
+ badge_icon = certifier&.icon_url
114
+ badge_click = 'https://socialcert.net'
115
+ [name, avatar_url, badge_label, badge_icon, badge_click]
116
+ end
117
+ private_class_method :parse_email_cert
118
+
119
+ def self.parse_phone_cert(fields, certifier)
120
+ name = fields['phoneNumber']
121
+ avatar_url = PHONE_AVATAR
122
+ badge_label = "Phone certified by #{certifier&.name}"
123
+ badge_icon = certifier&.icon_url
124
+ badge_click = 'https://socialcert.net'
125
+ [name, avatar_url, badge_label, badge_icon, badge_click]
126
+ end
127
+ private_class_method :parse_phone_cert
128
+
129
+ def self.parse_identi_cert(fields, certifier)
130
+ first = fields['firstName']
131
+ last = fields['lastName']
132
+ name = if present?(first) && present?(last)
133
+ "#{first} #{last}"
134
+ elsif present?(first)
135
+ first
136
+ elsif present?(last)
137
+ last
138
+ end
139
+ avatar_url = fields['profilePhoto']
140
+ badge_label = "Government ID certified by #{certifier&.name}"
141
+ badge_icon = certifier&.icon_url
142
+ badge_click = 'https://identicert.me'
143
+ [name, avatar_url, badge_label, badge_icon, badge_click]
144
+ end
145
+ private_class_method :parse_identi_cert
146
+
147
+ def self.parse_registrant(fields, certifier)
148
+ name = fields['name']
149
+ avatar_url = fields['icon']
150
+ badge_label = "Entity certified by #{certifier&.name}"
151
+ badge_icon = certifier&.icon_url
152
+ badge_click = 'https://projectbabbage.com/docs/registrant'
153
+ [name, avatar_url, badge_label, badge_icon, badge_click]
154
+ end
155
+ private_class_method :parse_registrant
156
+
157
+ def self.parse_cool_cert(fields)
158
+ name = fields['cool'] == 'true' ? 'Cool Person!' : 'Not cool!'
159
+ [name, nil, nil, nil, nil]
160
+ end
161
+ private_class_method :parse_cool_cert
162
+
163
+ def self.parse_anyone
164
+ [
165
+ 'Anyone',
166
+ ANYONE_AVATAR,
167
+ 'Represents the ability for anyone to access this information.',
168
+ BADGE_ICON,
169
+ 'https://projectbabbage.com/docs/anyone-identity'
170
+ ]
171
+ end
172
+ private_class_method :parse_anyone
173
+
174
+ def self.parse_self
175
+ [
176
+ 'You',
177
+ SELF_AVATAR,
178
+ 'Represents your ability to access this information.',
179
+ BADGE_ICON,
180
+ 'https://projectbabbage.com/docs/self-identity'
181
+ ]
182
+ end
183
+ private_class_method :parse_self
184
+
185
+ # -- Generic fallback ---------------------------------------------------
186
+
187
+ # Attempts to extract identity fields from an unknown certificate type by
188
+ # checking commonly used field names in order of preference.
189
+ def self.parse_generic(type_b64, fields, certifier)
190
+ default = Constants::DEFAULT_IDENTITY
191
+
192
+ name = resolve_generic_name(fields, default)
193
+ avatar = resolve_generic_avatar(fields, default)
194
+ b_label = resolve_generic_badge_label(type_b64, certifier, default)
195
+ b_icon = present?(certifier&.icon_url) ? certifier.icon_url : default.badge_icon_url
196
+ b_click = default.badge_click_url
197
+
198
+ [name, avatar, b_label, b_icon, b_click]
199
+ end
200
+ private_class_method :parse_generic
201
+
202
+ def self.resolve_generic_name(fields, default)
203
+ return fields['name'] if present?(fields['name'])
204
+ return fields['userName'] if present?(fields['userName'])
205
+
206
+ full_name = compose_full_name(fields)
207
+ return full_name if present?(full_name)
208
+
209
+ return fields['email'] if present?(fields['email'])
210
+
211
+ default.name
212
+ end
213
+ private_class_method :resolve_generic_name
214
+
215
+ def self.compose_full_name(fields)
216
+ first = fields['firstName']
217
+ last = fields['lastName']
218
+
219
+ if present?(first) && present?(last)
220
+ "#{first} #{last}"
221
+ elsif present?(first)
222
+ first
223
+ elsif present?(last)
224
+ last
225
+ end
226
+ end
227
+ private_class_method :compose_full_name
228
+
229
+ def self.resolve_generic_avatar(fields, default)
230
+ return fields['profilePhoto'] if present?(fields['profilePhoto'])
231
+ return fields['avatar'] if present?(fields['avatar'])
232
+ return fields['icon'] if present?(fields['icon'])
233
+ return fields['photo'] if present?(fields['photo'])
234
+
235
+ default.avatar_url
236
+ end
237
+ private_class_method :resolve_generic_avatar
238
+
239
+ def self.resolve_generic_badge_label(type_b64, certifier, default)
240
+ return "#{type_b64} certified by #{certifier.name}" if present?(certifier&.name)
241
+
242
+ default.badge_label
243
+ end
244
+ private_class_method :resolve_generic_badge_label
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Identity
5
+ # Formatted identity information for display in user interfaces.
6
+ class DisplayableIdentity
7
+ # @return [String] human-readable display name
8
+ attr_reader :name
9
+
10
+ # @return [String] URL or opaque string for the identity avatar image
11
+ attr_reader :avatar_url
12
+
13
+ # @return [String] shortened version of the identity key for compact display
14
+ attr_reader :abbreviated_key
15
+
16
+ # @return [String] full identity public key
17
+ attr_reader :identity_key
18
+
19
+ # @return [String, nil] URL or opaque string for a trust badge icon
20
+ attr_reader :badge_icon_url
21
+
22
+ # @return [String, nil] human-readable badge label (e.g. certifier name)
23
+ attr_reader :badge_label
24
+
25
+ # @return [String, nil] URL to open when the badge is clicked
26
+ attr_reader :badge_click_url
27
+
28
+ # @param name [String]
29
+ # @param avatar_url [String]
30
+ # @param abbreviated_key [String]
31
+ # @param identity_key [String]
32
+ # @param badge_icon_url [String, nil]
33
+ # @param badge_label [String, nil]
34
+ # @param badge_click_url [String, nil]
35
+ def initialize(name:, avatar_url:, abbreviated_key:, identity_key:,
36
+ badge_icon_url: nil, badge_label: nil, badge_click_url: nil)
37
+ @name = name
38
+ @avatar_url = avatar_url
39
+ @abbreviated_key = abbreviated_key
40
+ @identity_key = identity_key
41
+ @badge_icon_url = badge_icon_url
42
+ @badge_label = badge_label
43
+ @badge_click_url = badge_click_url
44
+ end
45
+ end
46
+
47
+ # Certifier metadata attached to a certificate for display purposes.
48
+ class CertifierInfo
49
+ # @return [String] certifier's display name
50
+ attr_reader :name
51
+
52
+ # @return [String, nil] URL or opaque string for the certifier's icon
53
+ attr_reader :icon_url
54
+
55
+ # @param name [String]
56
+ # @param icon_url [String, nil]
57
+ def initialize(name:, icon_url: nil)
58
+ @name = name
59
+ @icon_url = icon_url
60
+ end
61
+ end
62
+
63
+ # A certificate together with its decrypted field values and optional certifier info.
64
+ class IdentityCertificate
65
+ # @return [Hash] raw certificate data (type, subject, fields, etc.)
66
+ attr_reader :certificate
67
+
68
+ # @return [Hash] certificate field values after decryption
69
+ attr_reader :decrypted_fields
70
+
71
+ # @return [CertifierInfo, nil] display information about the certifier
72
+ attr_reader :certifier_info
73
+
74
+ # @param certificate [Hash]
75
+ # @param decrypted_fields [Hash]
76
+ # @param certifier_info [CertifierInfo, nil]
77
+ def initialize(certificate:, decrypted_fields:, certifier_info: nil)
78
+ @certificate = certificate
79
+ @decrypted_fields = decrypted_fields
80
+ @certifier_info = certifier_info
81
+ end
82
+ end
83
+
84
+ # Configuration options for an IdentityClient instance.
85
+ class ClientOptions
86
+ # @return [Array] BRC-42/43 wallet protocol identifier, e.g. [1, 'identity']
87
+ attr_reader :protocol_id
88
+
89
+ # @return [String] key identifier within the protocol
90
+ attr_reader :key_id
91
+
92
+ # @return [Integer] token amount in satoshis for identity operations
93
+ attr_reader :token_amount
94
+
95
+ # @return [Integer] output index within the token transaction
96
+ attr_reader :output_index
97
+
98
+ # @param protocol_id [Array]
99
+ # @param key_id [String]
100
+ # @param token_amount [Integer]
101
+ # @param output_index [Integer]
102
+ def initialize(protocol_id:, key_id:, token_amount:, output_index:)
103
+ @protocol_id = protocol_id
104
+ @key_id = key_id
105
+ @token_amount = token_amount
106
+ @output_index = output_index
107
+ end
108
+
109
+ # Default options matching the TS SDK DEFAULT_IDENTITY_CLIENT_OPTIONS constant.
110
+ DEFAULT = new(
111
+ protocol_id: [1, 'identity'].freeze,
112
+ key_id: '1',
113
+ token_amount: 1,
114
+ output_index: 0
115
+ ).freeze
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ # Identity module for BSV blockchain.
5
+ #
6
+ # Provides data structures and constants for resolving and displaying
7
+ # identity information associated with BSV public keys, backed by
8
+ # verifiable certificates managed through the BSV overlay network.
9
+ module Identity
10
+ autoload :DisplayableIdentity, 'bsv/identity/types'
11
+ autoload :CertifierInfo, 'bsv/identity/types'
12
+ autoload :IdentityCertificate, 'bsv/identity/types'
13
+ autoload :ClientOptions, 'bsv/identity/types'
14
+ autoload :Constants, 'bsv/identity/constants'
15
+ autoload :IdentityParser, 'bsv/identity/identity_parser'
16
+ autoload :Client, 'bsv/identity/client'
17
+ end
18
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Overlay
5
+ # Script template for creating, unlocking, and decoding SHIP and SLAP advertisements.
6
+ #
7
+ # SHIP (Service Host Interconnect) and SLAP (Service Lookup Availability)
8
+ # tokens are PushDrop scripts containing four data fields:
9
+ #
10
+ # Field 0: protocol string — 'SHIP' or 'SLAP'
11
+ # Field 1: identity key — 33-byte compressed public key (binary)
12
+ # Field 2: domain — UTF-8 string
13
+ # Field 3: topic or service name — UTF-8 string
14
+ #
15
+ # The locking script includes a fifth field containing a wallet signature
16
+ # over the concatenation of fields 0–3, which authenticates the token at
17
+ # creation time. The lock is secured with a P2PK condition derived from the
18
+ # wallet using BRC-42/43 key derivation at security level 2.
19
+ #
20
+ # Script layout (lock-after PushDrop format):
21
+ #
22
+ # <protocol> <identity_key> <domain> <topic> <wallet_sig>
23
+ # OP_2DROP OP_2DROP OP_DROP
24
+ # <derived_pubkey> OP_CHECKSIG
25
+ #
26
+ # @example Lock a SHIP advertisement
27
+ # wallet = BSV::Wallet::ProtoWallet.new(private_key)
28
+ # template = BSV::Overlay::AdminTokenTemplate.new(wallet)
29
+ # locking_script = template.lock('SHIP', 'myhost.example.com', 'tm_payments')
30
+ # decoded = BSV::Overlay::AdminTokenTemplate.decode(locking_script)
31
+ # decoded.identity_key # => hex public key of the wallet
32
+ class AdminTokenTemplate
33
+ # Decoded representation of a SHIP or SLAP advertisement.
34
+ class Advertisement
35
+ # @return [String] protocol identifier — 'SHIP' or 'SLAP'
36
+ attr_reader :protocol
37
+
38
+ # @return [String] hex-encoded compressed public key (33 bytes)
39
+ attr_reader :identity_key
40
+
41
+ # @return [String] domain where the topic or service is available
42
+ attr_reader :domain
43
+
44
+ # @return [String] topic or service name being advertised
45
+ attr_reader :topic_or_service
46
+
47
+ # @param protocol [String]
48
+ # @param identity_key [String]
49
+ # @param domain [String]
50
+ # @param topic_or_service [String]
51
+ def initialize(protocol:, identity_key:, domain:, topic_or_service:)
52
+ @protocol = protocol
53
+ @identity_key = identity_key
54
+ @domain = domain
55
+ @topic_or_service = topic_or_service
56
+ end
57
+ end
58
+
59
+ # Unlocker returned by {#unlock}.
60
+ #
61
+ # Satisfies the P2PK condition in a PushDrop locking script by signing
62
+ # the BIP-143 sighash of the spending transaction using the wallet's
63
+ # derived key for the appropriate protocol.
64
+ class Unlocker
65
+ # Estimated length of a P2PK unlocking script: 1 push opcode + up to
66
+ # 72 DER-encoded signature bytes + 1 sighash byte = 73 bytes total.
67
+ ESTIMATED_LENGTH = 73
68
+
69
+ # @param wallet [#create_signature] BRC-100 wallet interface
70
+ # @param protocol_id [Array] two-element array [security_level, protocol_name]
71
+ # @param originator [String, nil] optional originator domain
72
+ def initialize(wallet, protocol_id, originator)
73
+ @wallet = wallet
74
+ @protocol_id = protocol_id
75
+ @originator = originator
76
+ end
77
+
78
+ # Generate the unlocking script for the given input.
79
+ #
80
+ # Computes the BIP-143 sighash (SIGHASH_ALL|FORK_ID) and signs it
81
+ # using the wallet's derived key for the protocol.
82
+ #
83
+ # @param tx [BSV::Transaction::Transaction] the spending transaction
84
+ # @param input_index [Integer] which input to sign
85
+ # @return [BSV::Script::Script] the unlocking script
86
+ def sign(tx, input_index)
87
+ sighash_type = BSV::Transaction::Sighash::ALL_FORK_ID
88
+ hash = tx.sighash(input_index, sighash_type)
89
+ hash_bytes = hash.unpack('C*')
90
+
91
+ sig_args = { hash_to_directly_sign: hash_bytes, protocol_id: @protocol_id, key_id: '1', counterparty: 'self' }
92
+ sig_args[:originator] = @originator if @originator
93
+ result = @wallet.create_signature(sig_args)
94
+
95
+ sig_bytes = result[:signature].pack('C*')
96
+ sig_with_hashtype = sig_bytes + [sighash_type].pack('C')
97
+ BSV::Script::Script.pushdrop_unlock(
98
+ BSV::Script::Script.p2pk_unlock(sig_with_hashtype)
99
+ )
100
+ end
101
+
102
+ # Estimated byte length of the unlocking script.
103
+ #
104
+ # @param _tx [BSV::Transaction::Transaction] unused
105
+ # @param _input_index [Integer] unused
106
+ # @return [Integer]
107
+ def estimated_length(_tx, _input_index)
108
+ ESTIMATED_LENGTH
109
+ end
110
+ end
111
+
112
+ VALID_PROTOCOLS = [Constants::PROTOCOL_SHIP, Constants::PROTOCOL_SLAP].freeze
113
+ private_constant :VALID_PROTOCOLS
114
+
115
+ # Decode a SHIP or SLAP advertisement from a PushDrop locking script.
116
+ #
117
+ # @param script [BSV::Script::Script, nil] the locking script to decode
118
+ # @return [Advertisement, nil] the decoded advertisement, or +nil+ if the
119
+ # script is nil, empty, or not a PushDrop script
120
+ # @raise [BSV::Overlay::OverlayError] if the script is PushDrop but has
121
+ # fewer than 4 fields, or if the protocol field is not 'SHIP' or 'SLAP'
122
+ def self.decode(script)
123
+ return nil if script.nil? || script.bytes.empty?
124
+ return nil unless script.pushdrop?
125
+
126
+ fields = script.pushdrop_fields
127
+ raise OverlayError, 'Invalid SHIP/SLAP advertisement: expected at least 4 fields' if fields.length < 4
128
+
129
+ protocol = fields[0].force_encoding('UTF-8')
130
+ raise OverlayError, "Invalid SHIP/SLAP protocol: #{protocol.inspect}" unless VALID_PROTOCOLS.include?(protocol)
131
+
132
+ identity_key = fields[1].unpack1('H*')
133
+ domain = normalise_field(fields[2])
134
+ topic_or_service = normalise_field(fields[3])
135
+
136
+ Advertisement.new(
137
+ protocol: protocol,
138
+ identity_key: identity_key,
139
+ domain: domain,
140
+ topic_or_service: topic_or_service
141
+ )
142
+ end
143
+
144
+ # Construct a new AdminTokenTemplate instance.
145
+ #
146
+ # @param wallet [#get_public_key, #create_signature] any object implementing
147
+ # the BRC-100 wallet interface (e.g. {BSV::Wallet::ProtoWallet})
148
+ # @param originator [String, nil] optional FQDN of the originating application
149
+ def initialize(wallet, originator: nil)
150
+ @wallet = wallet
151
+ @originator = originator
152
+ end
153
+
154
+ # Create a SHIP or SLAP advertisement locking script.
155
+ #
156
+ # Derives the wallet's identity key, builds the four advertisement fields,
157
+ # signs them with the protocol-derived key, and constructs a PushDrop
158
+ # locking script with a P2PK spending condition.
159
+ #
160
+ # @param protocol [String] 'SHIP' or 'SLAP'
161
+ # @param domain [String] domain where the service or topic is available
162
+ # @param topic_or_service [String] topic or service name to advertise
163
+ # @return [BSV::Script::Script] the locking script
164
+ # @raise [BSV::Overlay::OverlayError] if protocol is not 'SHIP' or 'SLAP'
165
+ def lock(protocol, domain, topic_or_service)
166
+ raise OverlayError, "Invalid protocol: #{protocol.inspect}. Must be 'SHIP' or 'SLAP'" \
167
+ unless VALID_PROTOCOLS.include?(protocol)
168
+
169
+ protocol_id = protocol_id_for(protocol)
170
+
171
+ # Fetch the wallet's identity key (compressed public key hex)
172
+ id_args = { identity_key: true }
173
+ id_args[:originator] = @originator if @originator
174
+ identity_result = @wallet.get_public_key(id_args)
175
+ identity_key_hex = identity_result[:public_key]
176
+ identity_key_bytes = [identity_key_hex].pack('H*')
177
+
178
+ # Derive the locking public key for this protocol
179
+ lock_args = { protocol_id: protocol_id, key_id: '1', counterparty: 'self' }
180
+ lock_args[:originator] = @originator if @originator
181
+ locking_result = @wallet.get_public_key(lock_args)
182
+ locking_pubkey_hex = locking_result[:public_key]
183
+ locking_pubkey_bytes = [locking_pubkey_hex].pack('H*')
184
+
185
+ # Build the four advertisement fields
186
+ field_protocol = protocol.b
187
+ field_identity = identity_key_bytes
188
+ field_domain = domain.encode('binary')
189
+ field_topic = topic_or_service.encode('binary')
190
+
191
+ # Sign the concatenation of all four fields as authentication
192
+ data_to_sign = (field_protocol + field_identity + field_domain + field_topic).unpack('C*')
193
+ sig_args = { data: data_to_sign, protocol_id: protocol_id, key_id: '1', counterparty: 'self' }
194
+ sig_args[:originator] = @originator if @originator
195
+ sig_result = @wallet.create_signature(sig_args)
196
+ field_sig = sig_result[:signature].pack('C*')
197
+
198
+ fields = [field_protocol, field_identity, field_domain, field_topic, field_sig]
199
+ lock_script = BSV::Script::Script.p2pk_lock(locking_pubkey_bytes)
200
+ BSV::Script::Script.pushdrop_lock(fields, lock_script)
201
+ end
202
+
203
+ # Return an unlocker for spending an advertisement token.
204
+ #
205
+ # The returned object follows the {BSV::Transaction::UnlockingScriptTemplate}
206
+ # interface and can be assigned to an input's +unlocking_script_template+.
207
+ #
208
+ # @param protocol [String] 'SHIP' or 'SLAP' — must match the locked token
209
+ # @return [Unlocker] an object with +#sign+ and +#estimated_length+
210
+ # @raise [BSV::Overlay::OverlayError] if protocol is not 'SHIP' or 'SLAP'
211
+ def unlock(protocol)
212
+ raise OverlayError, "Invalid protocol: #{protocol.inspect}. Must be 'SHIP' or 'SLAP'" \
213
+ unless VALID_PROTOCOLS.include?(protocol)
214
+
215
+ Unlocker.new(@wallet, protocol_id_for(protocol), @originator)
216
+ end
217
+
218
+ class << self
219
+ private
220
+
221
+ # Normalise a field value: decode as UTF-8 and treat a single null byte
222
+ # as an empty string. This matches the encoding used when an empty string
223
+ # is represented as a raw +\x00+ byte push rather than OP_0.
224
+ def normalise_field(raw)
225
+ return '' if raw.bytesize == 1 && raw.getbyte(0).zero?
226
+
227
+ raw.force_encoding('UTF-8')
228
+ end
229
+ end
230
+
231
+ private
232
+
233
+ # Map a protocol string to its BRC-42/43 protocol ID array for key derivation.
234
+ #
235
+ # Uses the lowercase derivation names required by the wallet key-derivation
236
+ # validator, matching the Go SDK convention (not the titlecase TS SDK variant).
237
+ #
238
+ # @param protocol [String] 'SHIP' or 'SLAP'
239
+ # @return [Array] [security_level, protocol_name]
240
+ def protocol_id_for(protocol)
241
+ if protocol == Constants::PROTOCOL_SHIP
242
+ [2, Constants::DERIVE_PROTOCOL_SHIP]
243
+ else
244
+ [2, Constants::DERIVE_PROTOCOL_SLAP]
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end