cardano-bech32 0.3.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3cc7e539a8a0e31cc6e25a42a84ba58419f9ce689be6be07f6e92cc8d6f4a04c
4
- data.tar.gz: 04a8ff0509d920547f2e9f181e7659c3743fee575bf7f11ea61c35f9c0111fb2
3
+ metadata.gz: c13d50b51db3aad428e4dc97ed0036c8bdd2bc04340a13af4d5634f372015300
4
+ data.tar.gz: 9ec070ab4fd53ad9b6cfc0e64d7bb9de66cee2bb6644b235f4557eebc96feabc
5
5
  SHA512:
6
- metadata.gz: 90641e5ad5e7a0bfcd19e061fa8a02ed8442a9f25a7e023cc43ce447e7536270ae06029fdaef142ab92fe55be914d731d21ee7476236f359de1ebed5cfdd88eb
7
- data.tar.gz: 29b7495b3e916622a6d3792fc412f2bc8471879c1d8a4f4ec9ba3e135c66ff0e814366fceefabe7472a2a9e6f0c1ac4b4870921f3952b1318f0516891892d28c
6
+ metadata.gz: ebbf68a3fb7bbcd5b07530d435cc78901fdc1c64e8135a87d12bb053b4fb70088da9fa2ea540ec10f85863efad2e750c4510d475e87059bd62a778b5b98c7b7b
7
+ data.tar.gz: 34b20e1373a2d0953f04d2fca08e465b4dc2549b4df53c3bcd08207dccc7aad7b945a955918ce6c748752d005927595053394668097c62b04ba7c02bd410598f
data/CHANGELOG.md CHANGED
@@ -8,3 +8,14 @@
8
8
  - Decoding of `gov_action` Bech32 identifiers into transaction ID and index
9
9
  - Validation helpers for governance action identifiers
10
10
  - RSpec test suite with normative CIP-0129 test vectors
11
+
12
+ ## [0.1.0] - 2026-01-05
13
+
14
+ ### Added
15
+ - Encoding of DRep IDs `drep` as defined in CIP-0129
16
+ - Decoding of `drep` Bech32 identifiers into hex and byte data
17
+ - Encoding of CC (cold / hot) IDs `cc_cold` `cc_hot` as defined in CIP-0129
18
+ - Decoding of `cc_cold` `cc_hot` into hex and byte data
19
+ - Encoding of Stake Pool identifier `pool`
20
+ - Decoding of Stake Pool identifiers into hex and byte data
21
+ - Decoding of Addresses such as Base, Stake, Pointer, Enterprise into network info, byte data
data/README.md CHANGED
@@ -1,22 +1,28 @@
1
1
  # cardano-bech32
2
2
 
3
3
  A small, focused Ruby library for encoding and decoding Cardano Bech32 identifiers.
4
- This gem deliberately avoids higher-level ledger concerns and focuses solely on correct, specification-compliant Bech32 handling.
4
+ This gem deliberately avoids higher-level ledger concerns and focuses solely on **specification-compliant Bech32 handling**.
5
5
 
6
- Currently supported
6
+ ## Supported Identifiers
7
7
 
8
- - [x] Governance Action IDs (gov_action) — [CIP-0129](https://cips.cardano.org/cip/CIP-0129)
9
- - [ ] Addresses
10
- - [ ] Stake pool IDs
11
- - [ ] DRep IDs
8
+ - [x] Governance [CIP-0129](https://cips.cardano.org/cip/CIP-0129)
9
+ - [x] GovAction IDs
10
+ - [x] DRep IDs
11
+ - [x] CC hot/cold
12
+ - [x] Addresses
13
+ - [x] Base Address
14
+ - [x] Enterprise Address
15
+ - [x] Pointer Address
16
+ - [x] Stake Address
17
+ - [x] Stake Pool IDs
12
18
 
13
19
  The API is designed to grow conservatively as additional Cardano Improvement Proposals (CIPs) are implemented.
14
20
 
15
21
  ## Installation
16
22
 
17
- Add this line to your application’s Gemfile:
23
+ Add to your Gemfile:
18
24
 
19
- ```sh
25
+ ```ruby
20
26
  gem "cardano-bech32"
21
27
  ```
22
28
 
@@ -34,79 +40,184 @@ gem install cardano-bech32
34
40
 
35
41
  ## Usage
36
42
 
37
- ### Governance Action IDs (CIP-0129)
43
+ ### Governance Credentials (CIP-0129)
38
44
 
39
- A Governance Action ID is derived as:
40
- tx_id (32 bytes) || index (1 byte)
41
- → Bech32 encode with HRP "gov_action"
45
+ Cardano governance identifiers defined in CIP-0129 are Bech32-encoded binary payloads used throughout on-chain governance.
42
46
 
43
- #### Encoding
47
+ This library supports all CIP-0129 governance identifiers, including:
44
48
 
45
- ```ruby
46
- require "cardano/bech32"
49
+ * Constitutional Committee credentials (hot & cold)
50
+ * DRep credentials
51
+ * Governance Action IDs
47
52
 
48
- txref = "b2a591ac219ce6dcca5847e0248015209c7cb0436aa6bd6863d0c1f152a60bc5#0"
53
+ #### Constitutional Committee & DRep Credentials
49
54
 
50
- Cardano::Bech32::GovAction.encode(txref)
51
- # => "gov_action1k2jertppnnndejjcglszfqq4yzw8evzrd2nt66rr6rqlz54xp0zsq05ecsn"
55
+ Governance credentials consist of:
56
+
57
+ ```
58
+ header (1 byte) || credential hash (N bytes)
52
59
  ```
53
60
 
54
- #### Decoding
61
+ The header byte is authoritative and encodes:
62
+ * Credential type (CC Hot, CC Cold, DRep)
63
+ * Key type (key hash or script hash)
55
64
 
56
- ```ruby
57
- Cardano::Bech32::GovAction.decode(
58
- "gov_action1k2jertppnnndejjcglszfqq4yzw8evzrd2nt66rr6rqlz54xp0zsq05ecsn"
65
+ The HRP is derived from the header and validated during decoding.
66
+
67
+ Decode a governance credential
68
+
69
+ ```
70
+ require "cardano/bech32"
71
+
72
+ cred = Cardano::Bech32::GovCredentials.decode(
73
+ "cc_cold1zvqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq6kflvs"
59
74
  )
60
- # => {
61
- tx_id: "b2a591ac219ce6dcca5847e0248015209c7cb0436aa6bd6863d0c1f152a60bc5",
62
- index: 0
63
- }
75
+
76
+ cred.credential # => :cc_cold
77
+ cred.hash_bytes # => [0, 0, 0, ...]
78
+ cred.header_byte # => 19
79
+ cred.hrp # => "cc_cold"
80
+ cred.key_type # => :script
81
+ cred.payload_bytes # => [19, 0, 0, ...]
82
+ cred.payload_hex # => "1300000000..."
64
83
  ```
65
84
 
66
- #### Validation
85
+ The returned object is one of:
86
+ * `Cardano::Bech32::GovCredentials::Cc`
87
+ * `Cardano::Bech32::GovCredentials::Drep`
67
88
 
68
- Validation checks:
69
- * Bech32 checksum
70
- * Correct HRP (gov_action)
71
- * Correct payload length (33 bytes)
89
+ Both inherit from `AbstractCredential` and expose a uniform interface.
90
+
91
+ Encode a governance credential
92
+
93
+ ```
94
+ payload_hex = "130000000000000000000000000000000000000000000000000000000000"
95
+
96
+ bech32 = Cardano::Bech32::GovCredentials.encode(payload_hex)
97
+ # => "cc_cold1zvqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq6kflvs"
98
+ ```
99
+
100
+ The encoder:
101
+ * Accepts raw bytes or hex
102
+ * Infers HRP from the header byte
103
+
104
+
105
+ #### Governance Action IDs
106
+
107
+ Governance Action IDs identify a governance action on-chain and consist of:
108
+
109
+
110
+ ```
111
+ tx_id (32 bytes) || index (1 byte)
112
+ ```
113
+
114
+ They are encoded as Bech32 using HRP `gov_action`.
72
115
 
73
116
  ```ruby
74
- Cardano::Bech32::GovAction.valid?("gov_action1k2jertppnnndejjcglszfqq4yzw8evzrd2nt66rr6rqlz54xp0zsq05ecsn")
117
+ require "cardano/bech32"
118
+
119
+ txref = "b2a591ac219ce6dcca5847e0248015209c7cb0436aa6bd6863d0c1f152a60bc5#0"
120
+ bech32 = Cardano::Bech32::GovAction.encode(txref)
121
+ # => "gov_action1k2jertppnnndejjcglszfqq4yzw8evzrd2nt66rr6rqlz54xp0zsq05ecsn"
122
+
123
+ gov_action = Cardano::Bech32::GovAction.decode(bech32)
124
+ gov_action.tx_id # => "b2a591ac219ce6dcca5847e0248015209c7cb0436aa6bd6863d0c1f152a60bc5"
125
+ gov_action.index # => 0
126
+ gov_action.payload_bytes # => [178, 165, 145, 172, ...]
127
+ gov_action.hrp # => "gov_action"
128
+
129
+ Cardano::Bech32::GovAction.valid?(bech32)
75
130
  # => true
76
131
  ```
77
132
 
78
- ### Error Handling
79
-
80
- The library normalizes all Bech32 decoding and validation failures into
81
- explicit, typed errors:
133
+ All decoding/validation failures for `GovAction` raise explicit, typed errors:
82
134
 
83
- * InvalidFormat — malformed input, wrong HRP, invalid Bech32
84
- * InvalidPayload — incorrect byte length, invalid index
135
+ * `Cardano::Bech32::GovAction::InvalidFormat` — malformed input, wrong HRP, invalid Bech32
136
+ * `Cardano::Bech32::GovAction::InvalidPayload` — incorrect payload length, invalid index
85
137
 
86
138
  ```ruby
87
- # Example
88
139
  begin
89
- Cardano::Bech32::GovAction.encode("invalid")
90
- rescue Cardano::Bech32::GovAction::Error => e
140
+ Cardano::Bech32::GovAction.decode("invalid")
141
+ rescue Cardano::Bech32::GovAction::InvalidFormat,
142
+ Cardano::Bech32::GovAction::InvalidPayload => e
91
143
  puts e.message
92
144
  end
145
+ ```
146
+
93
147
 
148
+
149
+ ### Stake Pool IDs
150
+
151
+ Stake Pool IDs are Bech32-encoded 28-byte hashes identifying a registered stake pool.
152
+
153
+ ```ruby
154
+ pool_hash = "6e90911fdb579e203f556f3f24aca5b8714be049ccf716008ab849fd"
155
+ Cardano::Bech32::StakePool.encode(pool_hash)
156
+ # => pool1d6gfz87m270zq064duljft99hpc5hczfenm3vqy2hpyl67tteq9
157
+
158
+ pool_id = "pool1d6gfz87m270zq064duljft99hpc5hczfenm3vqy2hpyl67tteq9"
159
+ Cardano::Bech32::StakePool.decode(pool_id)
160
+ # => { bytes: "\x8C\xB8\...", hex: "6e90911..." }
161
+
162
+ Cardano::Bech32::StakePool.valid?(pool_id)
163
+ # => true
164
+ ```
165
+
166
+ ### Addresses
167
+
168
+ ```ruby
169
+ # Decode a Cardano Address
170
+ addr_bech32 = "addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgse35a3x"
171
+ address = CardanoBech32::Address.decode(addr_bech32)
172
+
173
+ address.address_type # => :base, :stake, :pointer, or :enterprise
174
+ address.payment_credential # => :key or :script (nil for stake/pointer-only addresses)
175
+ address.stake_credential # => :key or :script (nil if not applicable)
176
+ address.network # => network string derived from HRP
94
177
  ```
95
178
 
179
+ #### Bech32 and Cardano Addresses
180
+
181
+ Some Bech32 libraries may report the variant incorrectly (Bech32 / Bech32m), but for Cardano this does **not affect validity**.
182
+
183
+ This library validates addresses based on:
184
+
185
+ * The HRP (`addr`, `addr_test`, `stake`, etc.)
186
+ * The header byte
187
+ * The network id encoded in the payload
188
+
189
+ Checksum and payload parsing are fully specification-compliant.
190
+
96
191
  ## Development
97
192
 
98
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
193
+ After checking out the repo:
99
194
 
100
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
195
+ ```
196
+ bin/setup # install dependencies
197
+ rake spec # run tests
198
+ bin/console # interactive console
199
+ ```
200
+
201
+ To install locally:
202
+
203
+ ```
204
+ bundle exec rake install
205
+ ```
206
+
207
+ To release a new version:
208
+
209
+ ```
210
+ # update version.rb
211
+ bundle exec rake release
212
+ ```
213
+
214
+ This creates a git tag, pushes commits, and uploads the gem to [rubygems.org](https://rubygems.org).
101
215
 
102
216
  ## Contributing
103
217
 
104
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/cardano_bech32. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/cardano_bech32/blob/main/CODE_OF_CONDUCT.md).
218
+ Bug reports and pull requests are welcome on GitHub at https://github.com/lacepool/cardano-bech32.
219
+ Please follow the [code of conduct](https://github.com/lacepool/cardano-bech32/blob/main/CODE_OF_CONDUCT.md).
105
220
 
106
221
  ## License
107
222
 
108
223
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
109
-
110
- ## Code of Conduct
111
-
112
- Everyone interacting in the Cardano::Bech32 project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/cardano_bech32/blob/main/CODE_OF_CONDUCT.md).
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cardano
4
+ module Bech32
5
+ module Address
6
+ # Abstract class for different types of Cardano Bech32 addresses
7
+ class AbstractAddress
8
+ attr_reader :hrp, :network, :payload_bytes, :header, :address_type, :payment_credential, :stake_credential
9
+
10
+ def initialize(hrp:, network:, payload_bytes:, header:)
11
+ @hrp = hrp
12
+ @network = network
13
+ @payload_bytes = payload_bytes
14
+ @header = header
15
+
16
+ @address_type = Address.address_type(header)
17
+
18
+ @payment_credential = Address.payment_credential_kind(@header, @address_type)
19
+ @stake_credential = Address.stake_credential_kind(@header, @address_type)
20
+ end
21
+
22
+ def base? = @address_type == :base
23
+ def pointer? = @address_type == :pointer
24
+ def enterprise? = @address_type == :enterprise
25
+ def stake? = @address_type == :stake
26
+
27
+ def script_payment? = @payment_credential == Address::Credential::SCRIPT
28
+ def script_stake? = @stake_credential == Address::Credential::SCRIPT
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "abstract_address"
4
+
5
+ module Cardano
6
+ module Bech32
7
+ module Address
8
+ class Payment < AbstractAddress; end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "abstract_address"
4
+
5
+ module Cardano
6
+ module Bech32
7
+ module Address
8
+ class Stake < AbstractAddress; end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bech32"
4
+ require_relative "address/payment"
5
+ require_relative "address/stake"
6
+
7
+ module Cardano
8
+ module Bech32
9
+ # Module for Cardano Bech32 addresses
10
+ module Address
11
+ module Credential
12
+ KEY = :key
13
+ SCRIPT = :script
14
+ end
15
+
16
+ module Type
17
+ BASE = :base
18
+ POINTER = :pointer
19
+ ENTERPRISE = :enterprise
20
+ STAKE = :stake
21
+ end
22
+
23
+ ADDRESS_TYPE_BY_HEADER = {
24
+ 0b0000 => Type::BASE,
25
+ 0b0001 => Type::BASE,
26
+ 0b0010 => Type::BASE,
27
+ 0b0011 => Type::BASE,
28
+ 0b0100 => Type::POINTER,
29
+ 0b0101 => Type::POINTER,
30
+ 0b0110 => Type::ENTERPRISE,
31
+ 0b0111 => Type::ENTERPRISE,
32
+ 0b1110 => Type::STAKE,
33
+ 0b1111 => Type::STAKE
34
+ }.freeze
35
+
36
+ def self.address_type(header)
37
+ high_nibble = high_nibble_from_header(header)
38
+
39
+ ADDRESS_TYPE_BY_HEADER.fetch(high_nibble) do
40
+ raise InvalidFormat, "Unknown header type: #{header.to_s(2)}"
41
+ end
42
+ end
43
+
44
+ # Payment credential: only exists for Base, Pointer, Enterprise
45
+ # Last bit of high nibble (0 = key, 1 = script)
46
+ def self.payment_credential_kind(header, address_type)
47
+ return nil if address_type == Type::STAKE
48
+
49
+ (high_nibble_from_header(header) & 0b0001).zero? ? Credential::KEY : Credential::SCRIPT
50
+ end
51
+
52
+ # Stake credential: varies by address type
53
+ def self.stake_credential_kind(header, address_type)
54
+ case address_type
55
+ when Type::STAKE
56
+ # last bit of high nibble: 0 = key, 1 = script
57
+ (high_nibble_from_header(header) & 0b0001).zero? ? Credential::KEY : Credential::SCRIPT
58
+ when Type::BASE
59
+ # third bit of high nibble: 1 = script, 0 = key
60
+ (high_nibble_from_header(header) & 0b0010).zero? ? Credential::KEY : Credential::SCRIPT
61
+ end
62
+ end
63
+
64
+ def self.network_symbol(header)
65
+ header & 0x0F == 1 ? :mainnet : :testnet
66
+ end
67
+
68
+ def self.high_nibble_from_header(header)
69
+ (header & 0b11110000) >> 4
70
+ end
71
+
72
+ def self.decode(bech32)
73
+ hrp, data = Cardano::Bech32.decode(bech32)
74
+ raise InvalidFormat, "invalid bech32 string" if hrp.nil? || data.nil?
75
+
76
+ # Convert 5-bit array to bytes
77
+ payload_bytes = Cardano::Bech32.convert_bits(data, from_bits: 5, to_bits: 8, pad: true)
78
+ raise InvalidPayload, "invalid payload length" unless payload_bytes.length.positive?
79
+
80
+ header = payload_bytes.first
81
+ type = address_type(header)
82
+ klass = type == :stake ? Stake : Payment
83
+
84
+ klass.new(
85
+ hrp: hrp,
86
+ network: network_symbol(header),
87
+ payload_bytes: payload_bytes,
88
+ header: header
89
+ )
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "header"
4
+
5
+ module Cardano
6
+ module Bech32
7
+ module GovCredentials
8
+ # Abstract base class for CIP-0129 governance credentials
9
+ #
10
+ # Header byte is authoritative and is interpreted exclusively
11
+ # via GovCredentials::Header.
12
+ class AbstractCredential
13
+ attr_reader :credential,
14
+ :hash_bytes,
15
+ :header_byte,
16
+ :hrp,
17
+ :key_type,
18
+ :payload_bytes,
19
+ :payload_hex
20
+
21
+ HASH_SIZE = 28
22
+
23
+ def initialize(hrp:, payload_bytes:)
24
+ @payload_bytes = payload_bytes
25
+ @header_byte = payload_bytes.first
26
+ @credential = Header.credential_type(@header_byte)
27
+ @hash_bytes = payload_bytes[1..]
28
+ @hrp = hrp
29
+ @key_type = Header.key_type(@header_byte)
30
+ @payload_hex = @payload_bytes.pack("C*").unpack1("H*").encode(Encoding::UTF_8)
31
+
32
+ validate!
33
+ end
34
+
35
+ # -----------------------------
36
+ # KeyType helpers
37
+ # -----------------------------
38
+
39
+ def key? = @key_type == Header::KeyType::KEY
40
+ def script? = @key_type == Header::KeyType::SCRIPT
41
+
42
+ # Returns a JSON-safe hash representation
43
+ #
44
+ # @return [Hash]
45
+ def to_h
46
+ {
47
+ credential: @credential,
48
+ hash_bytes: @hash_bytes,
49
+ header_byte: @header_byte,
50
+ hrp: @hrp,
51
+ key_type: @key_type,
52
+ payload_bytes: @payload_bytes,
53
+ payload_hex: @payload_hex
54
+ }
55
+ end
56
+
57
+ private
58
+
59
+ # -----------------------------
60
+ # Validation
61
+ # -----------------------------
62
+
63
+ def validate!
64
+ unless @hash_bytes.length == HASH_SIZE
65
+ raise InvalidPayload,
66
+ "invalid hash length: expected #{HASH_SIZE} bytes, got #{@hash_bytes.length}"
67
+ end
68
+
69
+ expected_hrp = Header.hrp(@header_byte)
70
+ unless @hrp == expected_hrp
71
+ raise InvalidFormat,
72
+ "HRP mismatch: expected #{expected_hrp}, got #{@hrp}"
73
+ end
74
+
75
+ return if @payload_bytes.length > 1
76
+
77
+ raise InvalidPayload,
78
+ "payload must include header + hash, got #{@payload_bytes.length} bytes"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "abstract_credential"
4
+
5
+ module Cardano
6
+ module Bech32
7
+ module GovCredentials
8
+ # Class for Cold and Hot CC credentials
9
+ class Cc < AbstractCredential
10
+ def hot? = @key_type == Header::KeyType::CC_HOT
11
+ def cold? = @key_type == Header::KeyType::CC_COLD
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bech32"
4
+ require_relative "cc"
5
+ require_relative "drep"
6
+ require_relative "header"
7
+ require_relative "abstract_credential"
8
+
9
+ module Cardano
10
+ module Bech32
11
+ # CIP-0129 Governance Credentials
12
+ module GovCredentials
13
+ class << self
14
+ # Decode a governance credential
15
+ #
16
+ # @param bech32 [String] Bech32 encoded governance credential
17
+ # @return [AbstractCredential, GovAction]
18
+ #
19
+ # Raises:
20
+ # - Cardano::Bech32::InvalidFormat
21
+ # - Cardano::Bech32::InvalidPayload
22
+ #
23
+ def decode(bech32)
24
+ hrp, data = Cardano::Bech32.decode(bech32)
25
+ raise InvalidFormat, "invalid bech32 string" if hrp.nil? || data.nil?
26
+
27
+ case hrp
28
+ when "gov_action"
29
+ GovAction.decode_from_data(data)
30
+ when "cc_hot", "cc_cold", "drep"
31
+ # when Header::HRP_BY_CREDENTIAL_TYPE.values
32
+ decode_credential(hrp, data)
33
+ else
34
+ raise InvalidFormat, "unknown governance HRP: #{hrp}"
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def decode_credential(hrp, data)
41
+ payload_bytes =
42
+ Cardano::Bech32.convert_bits(data, from_bits: 5, to_bits: 8, pad: false)
43
+
44
+ raise InvalidPayload, "empty payload" if payload_bytes.empty?
45
+
46
+ header_byte = payload_bytes.first
47
+ credential_type = Header.credential_type(header_byte)
48
+
49
+ klass =
50
+ case credential_type
51
+ when Header::CredentialType::CC_HOT, Header::CredentialType::CC_COLD
52
+ Cc
53
+ when Header::CredentialType::DREP
54
+ Drep
55
+ else
56
+ raise InvalidFormat, "unknown governance credential header: #{header_byte}"
57
+ end
58
+
59
+ klass.new(hrp: hrp, payload_bytes: payload_bytes)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "abstract_credential"
4
+
5
+ module Cardano
6
+ module Bech32
7
+ module GovCredentials
8
+ # Class for DRep credentials
9
+ class Drep < AbstractCredential; end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bech32"
4
+ require_relative "header"
5
+ require_relative "abstract_credential"
6
+
7
+ module Cardano
8
+ module Bech32
9
+ # CIP-0129 Governance Credentials
10
+ module GovCredentials
11
+ class << self
12
+ #
13
+ # Encode a governance-related Bech32 object
14
+ #
15
+ # Supports:
16
+ # - Governance credentials (header + hash payload)
17
+ # - Governance actions (txid#index)
18
+ #
19
+ # @param payload [String, Array<Integer>] Hex string or byte array
20
+ # @return [String] Bech32 encoded governance credential
21
+ #
22
+ # Raises:
23
+ # - ArgumentError
24
+ #
25
+ def encode(input)
26
+ case input
27
+ when String
28
+ if gov_action_ref?(input)
29
+ GovAction.encode(input)
30
+ else
31
+ encode_credential(input)
32
+ end
33
+ when Array
34
+ encode_credential(input)
35
+ else
36
+ raise ArgumentError, "invalid input type: #{input.class}"
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def encode_credential(payload)
43
+ bytes = bytes_from_payload(payload)
44
+ raise ArgumentError, "empty payload" if bytes.empty?
45
+
46
+ header_byte = bytes.first
47
+ hrp = Header.hrp(header_byte)
48
+ data = Cardano::Bech32.convert_bits(bytes, from_bits: 8, to_bits: 5, pad: true)
49
+
50
+ Cardano::Bech32.encode(hrp, data)
51
+ end
52
+
53
+ def gov_action_ref?(value)
54
+ value.include?("#")
55
+ end
56
+
57
+ def bytes_from_payload(payload)
58
+ case payload
59
+ when String
60
+ if payload.match?(/\A[0-9a-fA-F]+\z/) && payload.length.even?
61
+ [payload].pack("H*").bytes
62
+ else
63
+ payload.bytes
64
+ end
65
+ when Array
66
+ payload
67
+ else
68
+ raise ArgumentError, "invalid payload type: #{payload.class}"
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bech32"
4
+
5
+ module Cardano
6
+ module Bech32
7
+ module GovCredentials
8
+ # Bech32 encoding for governance action references.
9
+ # A governance action reference is of the form "txid#index"
10
+ # where txid is a 32-byte hex string and index is a 0..255 integer
11
+ # The Bech32 encoding has HRP "gov_action" and a payload of 33 bytes:
12
+ # - 32 bytes: txid (binary)
13
+ # - 1 byte: index (binary)
14
+ #
15
+ class GovAction
16
+ HRP = "gov_action"
17
+ TX_ID_BYTES = 32
18
+ INDEX_BYTES = 1
19
+ TOTAL_BYTES = TX_ID_BYTES + INDEX_BYTES
20
+
21
+ attr_reader :tx_id, :index, :payload_bytes, :hrp
22
+
23
+ def initialize(hrp:, payload_bytes:)
24
+ @payload_bytes = payload_bytes
25
+ @index = payload_bytes.last
26
+ @tx_id = payload_bytes[0, TX_ID_BYTES].pack("C*").unpack1("H*").encode(Encoding::UTF_8)
27
+ @hrp = hrp
28
+
29
+ validate!
30
+ end
31
+
32
+ def self.encode(tx_ref)
33
+ txid_hex, index_str = tx_ref.split("#", 2)
34
+ index = index_str.to_i if index_str
35
+
36
+ raise InvalidFormat, "expected txid#index" unless txid_hex && index
37
+ raise InvalidFormat, "invalid hex" unless txid_hex.match?(/\A[0-9a-fA-F]{64}\z/)
38
+
39
+ payload_bytes = [txid_hex].pack("H*").bytes + [index]
40
+
41
+ raise InvalidPayload, "invalid payload length" unless payload_bytes.length == TOTAL_BYTES
42
+ raise InvalidPayload, "index must be 0..255" unless index.between?(0, 255)
43
+
44
+ data = Cardano::Bech32.convert_bits(payload_bytes, from_bits: 8, to_bits: 5, pad: true)
45
+
46
+ Cardano::Bech32.encode(HRP, data)
47
+ end
48
+
49
+ def self.decode(bech32)
50
+ hrp, data = Cardano::Bech32.decode(bech32)
51
+ raise InvalidFormat, "invalid HRP" unless hrp == HRP
52
+
53
+ decode_from_data(data)
54
+ end
55
+
56
+ def self.decode_from_data(data)
57
+ bytes = Cardano::Bech32.convert_bits(data, from_bits: 5, to_bits: 8, pad: false)
58
+ raise InvalidPayload, "invalid payload length" unless bytes.length == TOTAL_BYTES
59
+
60
+ new(hrp: HRP, payload_bytes: bytes)
61
+ rescue ArgumentError
62
+ raise InvalidFormat, "invalid bech32 encoding"
63
+ end
64
+
65
+ def self.valid?(bech32)
66
+ decode(bech32)
67
+ true
68
+ rescue Error
69
+ false
70
+ end
71
+
72
+ def validate!
73
+ return if @payload_bytes.length == TOTAL_BYTES
74
+
75
+ raise InvalidPayload,
76
+ "invalid payload length: expected #{TOTAL_BYTES} bytes, got #{@payload_bytes.length}"
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cardano
4
+ module Bech32
5
+ module GovCredentials
6
+ # Header byte utilities for Governance credentials
7
+ module Header
8
+ #
9
+ # CIP-0129 Header byte layout:
10
+ #
11
+ # 7 6 5 4 | 3 2 1 0
12
+ # --------+--------
13
+ # credential type | key type
14
+ #
15
+ # High nibble (credential type):
16
+ # 0000 = CC Hot
17
+ # 0001 = CC Cold
18
+ # 0010 = DRep
19
+ #
20
+ # Lower nibble (key type):
21
+ # 0010 = Key hash
22
+ # 0011 = Script hash
23
+ #
24
+
25
+ # -----------------------------
26
+ # Key Types (upper nibble)
27
+ # -----------------------------
28
+
29
+ module CredentialType
30
+ CC_HOT = :cc_hot
31
+ CC_COLD = :cc_cold
32
+ DREP = :drep
33
+ end
34
+
35
+ CREDENTIAL_TYPE_BY_NIBBLE = {
36
+ 0b0000 => CredentialType::CC_HOT,
37
+ 0b0001 => CredentialType::CC_COLD,
38
+ 0b0010 => CredentialType::DREP
39
+ }.freeze
40
+
41
+ NIBBLE_BY_CREDENTIAL_TYPE = CREDENTIAL_TYPE_BY_NIBBLE.invert.freeze
42
+
43
+ # -----------------------------
44
+ # Key Types (lower nibble)
45
+ # -----------------------------
46
+
47
+ module KeyType
48
+ KEY = :key
49
+ SCRIPT = :script
50
+ end
51
+
52
+ KEY_TYPE_BY_NIBBLE = {
53
+ 0b0010 => KeyType::KEY,
54
+ 0b0011 => KeyType::SCRIPT
55
+ }.freeze
56
+
57
+ NIBBLE_BY_KEY_TYPE = KEY_TYPE_BY_NIBBLE.invert.freeze
58
+
59
+ # -----------------------------
60
+ # HRP Mapping (by key type)
61
+ # -----------------------------
62
+
63
+ HRP_BY_CREDENTIAL_TYPE = {
64
+ CredentialType::CC_HOT => "cc_hot",
65
+ CredentialType::CC_COLD => "cc_cold",
66
+ CredentialType::DREP => "drep"
67
+ }.freeze
68
+
69
+ # -----------------------------
70
+ # Public API
71
+ # -----------------------------
72
+
73
+ def self.credential_type(header)
74
+ nibble = high_nibble(header)
75
+
76
+ CREDENTIAL_TYPE_BY_NIBBLE.fetch(nibble) do
77
+ raise InvalidFormat, "Unknown governance credential type: #{nibble.to_s(2)}"
78
+ end
79
+ end
80
+
81
+ def self.key_type(header)
82
+ nibble = low_nibble(header)
83
+
84
+ KEY_TYPE_BY_NIBBLE.fetch(nibble) do
85
+ raise InvalidFormat, "Unknown governance key type: #{nibble.to_s(2)}"
86
+ end
87
+ end
88
+
89
+ def self.hrp(header)
90
+ HRP_BY_CREDENTIAL_TYPE.fetch(credential_type(header))
91
+ end
92
+
93
+ # -----------------------------
94
+ # Header Construction
95
+ # -----------------------------
96
+
97
+ def self.build(key_type:, credential:)
98
+ cred_nibble = NIBBLE_BY_CREDENTIAL_TYPE.fetch(credential) do
99
+ raise ArgumentError, "Invalid credential type: #{credential}"
100
+ end
101
+
102
+ key_nibble = NIBBLE_BY_KEY_TYPE.fetch(key_type) do
103
+ raise ArgumentError, "Invalid key type: #{key_type}"
104
+ end
105
+
106
+ ((cred_nibble << 4) | key_nibble) & 0xFF
107
+ end
108
+
109
+ # -----------------------------
110
+ # Bit Helpers
111
+ # -----------------------------
112
+
113
+ def self.high_nibble(header)
114
+ (header & 0b1111_0000) >> 4
115
+ end
116
+
117
+ def self.low_nibble(header)
118
+ header & 0b0000_1111
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gov_credentials/header"
4
+ require_relative "gov_credentials/abstract_credential"
5
+ require_relative "gov_credentials/cc"
6
+ require_relative "gov_credentials/drep"
7
+ require_relative "gov_credentials/gov_action"
8
+ require_relative "gov_credentials/decode"
9
+ require_relative "gov_credentials/encode"
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bech32"
4
+
5
+ module Cardano
6
+ module Bech32
7
+ # Module for encoding and decoding Cardano stake pool identifiers.
8
+ module StakePool
9
+ HRP = "pool"
10
+ POOL_HASH_LENGTH = 28 # bytes (blake2b-224)
11
+
12
+ module_function
13
+
14
+ # Encode a stake pool hash into Bech32.
15
+ #
16
+ # @param pool_hash [String] 56-char hex string or 28-byte binary string
17
+ # @return [String] Bech32 stake pool id
18
+ def encode(pool_hash)
19
+ raise InvalidPayload, "pool_hash must be a String" unless pool_hash.is_a?(String)
20
+
21
+ payload =
22
+ if pool_hash.encoding == Encoding::BINARY
23
+ pool_hash
24
+ elsif pool_hash.match?(/\A[0-9a-fA-F]{56}\z/)
25
+ [pool_hash].pack("H*")
26
+ else
27
+ raise InvalidPayload, "pool_hash must be 28 bytes or 56 hex chars"
28
+ end
29
+
30
+ raise InvalidPayload, "invalid pool hash length" unless payload.bytesize == POOL_HASH_LENGTH
31
+
32
+ data = Cardano::Bech32.convert_bits(payload.bytes, from_bits: 8, to_bits: 5, pad: true)
33
+ Cardano::Bech32.encode(HRP, data)
34
+ end
35
+
36
+ # Decode a Bech32 stake pool id.
37
+ #
38
+ # @param bech32 [String]
39
+ # @return [Hash] with keys :bytes (28-byte binary string) and :hex (56-char hex string)
40
+ def decode(bech32)
41
+ hrp, data = Cardano::Bech32.decode(bech32)
42
+
43
+ raise InvalidFormat, "invalid bech32 string" unless hrp && data
44
+ raise InvalidFormat, "invalid stake pool HRP: #{hrp}" unless hrp == HRP
45
+
46
+ bytes = Cardano::Bech32.convert_bits(data, from_bits: 5, to_bits: 8, pad: false)
47
+ raise InvalidPayload, "invalid payload" unless bytes
48
+ raise InvalidPayload, "invalid pool hash length" unless bytes.length == POOL_HASH_LENGTH
49
+
50
+ {
51
+ bytes: bytes.pack("C*"),
52
+ hex: bytes.pack("C*").unpack1("H*")
53
+ }
54
+ end
55
+
56
+ # Check whether a Bech32 string is a valid stake pool id.
57
+ #
58
+ # @param bech32 [String]
59
+ # @return [Boolean]
60
+ def valid?(bech32)
61
+ decode(bech32)
62
+ true
63
+ rescue Error
64
+ false
65
+ end
66
+ end
67
+ end
68
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Cardano
4
4
  module Bech32
5
- VERSION = "0.3.0"
5
+ VERSION = "1.0.0"
6
6
  end
7
7
  end
@@ -1,11 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "bech32/version"
4
- require_relative "bech32/gov_action"
5
-
6
3
  module Cardano
4
+ # Bech32 module for encoding and decoding Cardano Bech32 identifiers.
7
5
  module Bech32
6
+ # Maximum length for a Bech32-encoded Cardano identifier.
7
+ # Chosen to be very high (2048) to allow all current and foreseeable
8
+ # identifiers while protecting against excessively long/malformed input.
9
+ MAX_BECH32_LENGTH = 2048
10
+
11
+ # Wrapper around bech32rb encode function.
12
+ # @param hrp [String] human-readable part
13
+ # @param data [Array<Integer>] data as array of integers
14
+ # @return [String] Bech32-encoded string
15
+ def self.encode(hrp, data)
16
+ ::Bech32.encode(hrp, data, ::Bech32::Encoding::BECH32)
17
+ end
18
+
19
+ # Wrapper around bech32rb decode function.
20
+ # @param bech32 [String]
21
+ # @return [Array(String, Array<Integer>), nil] human-readable part and data
22
+ def self.decode(bech32)
23
+ hrp, data, _spec = ::Bech32.decode(bech32, MAX_BECH32_LENGTH)
24
+ [hrp, data]
25
+ rescue StandardError
26
+ raise InvalidFormat, "invalid bech32 string"
27
+ end
28
+
29
+ # Convert bits between different bases.
30
+ # @param data [Array<Integer>] input data as array of integers
31
+ # @param from_bits [Integer] number of bits per input value
32
+ # @param to_bits [Integer] number of bits per output value
33
+ # @param pad [Boolean] whether to pad the output
34
+ # @return [Array<Integer>, nil] converted data as array of integers, or nil on failure
35
+ def self.convert_bits(data, from_bits: 5, to_bits: 8, pad: true)
36
+ ::Bech32.convert_bits(data, from_bits, to_bits, pad)
37
+ rescue StandardError
38
+ nil
39
+ end
40
+
8
41
  class Error < StandardError; end
9
- # Your code goes here...
42
+ class InvalidFormat < Error; end
43
+ class InvalidPayload < Error; end
10
44
  end
11
45
  end
46
+
47
+ require_relative "bech32/version"
48
+ require_relative "bech32/address"
49
+ require_relative "bech32/stake_pool"
50
+ require_relative "bech32/gov_credentials"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cardano-bech32
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robin Böning
@@ -53,7 +53,19 @@ files:
53
53
  - README.md
54
54
  - Rakefile
55
55
  - lib/cardano/bech32.rb
56
- - lib/cardano/bech32/gov_action.rb
56
+ - lib/cardano/bech32/address.rb
57
+ - lib/cardano/bech32/address/abstract_address.rb
58
+ - lib/cardano/bech32/address/payment.rb
59
+ - lib/cardano/bech32/address/stake.rb
60
+ - lib/cardano/bech32/gov_credentials.rb
61
+ - lib/cardano/bech32/gov_credentials/abstract_credential.rb
62
+ - lib/cardano/bech32/gov_credentials/cc.rb
63
+ - lib/cardano/bech32/gov_credentials/decode.rb
64
+ - lib/cardano/bech32/gov_credentials/drep.rb
65
+ - lib/cardano/bech32/gov_credentials/encode.rb
66
+ - lib/cardano/bech32/gov_credentials/gov_action.rb
67
+ - lib/cardano/bech32/gov_credentials/header.rb
68
+ - lib/cardano/bech32/stake_pool.rb
57
69
  - lib/cardano/bech32/version.rb
58
70
  - sig/cardano/bech32.rbs
59
71
  homepage: https://github.com/lacepool/cardano-bech32
@@ -1,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bech32"
4
-
5
- module Cardano
6
- module Bech32
7
- # Bech32 encoding for governance action references.
8
- # A governance action reference is of the form "txid#index"
9
- # where txid is a 32-byte hex string and index is a 0..255 integer
10
- # The Bech32 encoding has HRP "gov_action" and a payload of 33 bytes:
11
- # - 32 bytes: txid (binary)
12
- # - 1 byte: index (binary)
13
- #
14
- module GovAction
15
- HRP = "gov_action"
16
- TX_ID_BYTES = 32
17
- INDEX_BYTES = 1
18
- TOTAL_BYTES = TX_ID_BYTES + INDEX_BYTES
19
-
20
- class Error < StandardError; end
21
- class InvalidFormat < Error; end
22
- class InvalidPayload < Error; end
23
-
24
- def self.encode(tx_ref)
25
- txid_hex, index_str = tx_ref.split("#", 2)
26
- raise InvalidFormat, "expected txid#index" unless txid_hex && index_str
27
-
28
- txid_bytes = hex_to_bytes(txid_hex)
29
- index = Integer(index_str)
30
-
31
- raise InvalidPayload, "txid must be 32 bytes" unless txid_bytes.bytesize == TX_ID_BYTES
32
- raise InvalidPayload, "index must be 0..255" unless index.between?(0, 255)
33
-
34
- payload = txid_bytes + [index].pack("C")
35
-
36
- # Convert to 5-bit array
37
- # args: data (array of integers), from_bits, to_bits, pad (boolean)
38
- data_5bit = ::Bech32.convert_bits(payload.bytes, 8, 5, true)
39
- ::Bech32.encode(HRP, data_5bit, ::Bech32::Encoding::BECH32)
40
- end
41
-
42
- def self.decode(bech32)
43
- hrp, data = ::Bech32.decode(bech32)
44
- raise InvalidFormat, "invalid HRP" unless hrp == HRP
45
-
46
- # Convert to 5-bit array
47
- # args: data (array of integers), from_bits, to_bits, pad (boolean)
48
- bytes = ::Bech32.convert_bits(data, 5, 8, false)
49
- raise InvalidPayload, "invalid payload length" unless bytes.length == TOTAL_BYTES
50
-
51
- {
52
- tx_id: bytes_to_hex(bytes[0, TX_ID_BYTES]),
53
- index: bytes.last
54
- }
55
- rescue ArgumentError
56
- raise InvalidFormat, "invalid bech32 encoding"
57
- end
58
-
59
- def self.valid?(bech32)
60
- decode(bech32)
61
- true
62
- rescue Error
63
- false
64
- end
65
-
66
- def self.hex_to_bytes(hex)
67
- raise InvalidFormat, "invalid hex" unless hex.match?(/\A[0-9a-fA-F]{64}\z/)
68
-
69
- [hex].pack("H*")
70
- end
71
-
72
- def self.bytes_to_hex(bytes)
73
- bytes.pack("C*").unpack1("H*")
74
- end
75
- end
76
- end
77
- end