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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/lib/bsv/identity/client.rb +353 -0
- data/lib/bsv/identity/constants.rb +41 -0
- data/lib/bsv/identity/identity_parser.rb +247 -0
- data/lib/bsv/identity/types.rb +118 -0
- data/lib/bsv/identity.rb +18 -0
- data/lib/bsv/overlay/admin_token_template.rb +249 -0
- data/lib/bsv/overlay/broadcast_facilitator.rb +134 -0
- data/lib/bsv/overlay/constants.rb +52 -0
- data/lib/bsv/overlay/errors.rb +17 -0
- data/lib/bsv/overlay/host_reputation_tracker.rb +266 -0
- data/lib/bsv/overlay/lookup_facilitator.rb +125 -0
- data/lib/bsv/overlay/lookup_resolver.rb +406 -0
- data/lib/bsv/overlay/topic_broadcaster.rb +402 -0
- data/lib/bsv/overlay/types.rb +111 -0
- data/lib/bsv/overlay.rb +29 -0
- data/lib/bsv/script/push_drop_template.rb +207 -0
- data/lib/bsv/script.rb +6 -4
- data/lib/bsv/version.rb +1 -1
- data/lib/bsv/wallet_interface/proto_wallet.rb +9 -9
- data/lib/bsv-sdk.rb +2 -0
- metadata +18 -2
|
@@ -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
|
data/lib/bsv/identity.rb
ADDED
|
@@ -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
|