Skip to content

Commit 40a9a77

Browse files
jxomampcode-com
andauthored
feat: switch TempoAddress to bech32m (BIP-350) (#181)
* feat: switch TempoAddress to bech32m (BIP-350) - Add Bech32m module with encode/decode using BCH checksum (constant 0x2bc830a3) - Replace double-SHA256 checksum in TempoAddress with bech32m checksum - HRP included in checksum: swapping tempo/tempoz invalidates address - Test vectors match TIP-XXXX spec Amp-Thread-ID: https://ampcode.com/threads/T-019c9c5b-e584-7609-8a5b-ae0acf5fd603 Co-authored-by: Amp <amp@ampcode.com> * refactor: move internal fns to bottom of Bech32m with @internal Amp-Thread-ID: https://ampcode.com/threads/T-019c9c5b-e584-7609-8a5b-ae0acf5fd603 Co-authored-by: Amp <amp@ampcode.com> * fix: update test snapshots for bigint zoneId and mixed-case rejection Amp-Thread-ID: https://ampcode.com/threads/T-019ca0ae-6bf8-763e-844d-40e756d855a4 Co-authored-by: Amp <amp@ampcode.com> * feat(Bech32m, TempoAddress): add BIP-350 validations, version byte, and spec test vectors Bech32m: - encode: lowercase HRP, validate ASCII 33-126, enforce length limit (default 90) - decode: reject mixed-case, validate empty HRP, HRP ASCII range, length limit - Add MixedCaseError, InvalidHrpError, ExceedsLengthError - Add all 21 BIP-350 test vectors (7 valid + 14 invalid) TempoAddress: - Add version byte (0x00) to payload per TIP-XXXX spec - Validate version byte on parse, reject unrecognized versions - Add InvalidVersionError - Output matches all spec test vectors Amp-Thread-ID: https://ampcode.com/threads/T-019cabe6-4301-726d-bb00-421ba7b8dcc6 Co-authored-by: Amp <amp@ampcode.com> * fix: correct TempoAddress test snapshots for zone ID 252+ Amp-Thread-ID: https://ampcode.com/threads/T-019cabe6-4301-726d-bb00-421ba7b8dcc6 Co-authored-by: Amp <amp@ampcode.com> --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent 933bba9 commit 40a9a77

File tree

7 files changed

+663
-77
lines changed

7 files changed

+663
-77
lines changed

src/core/Bech32m.ts

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import type * as Errors from './Errors.js'
2+
import { BaseError } from './Errors.js'
3+
4+
/**
5+
* Encodes data bytes with a human-readable part (HRP) into a bech32m string (BIP-350).
6+
*
7+
* @example
8+
* ```ts twoslash
9+
* import { Bech32m } from 'ox'
10+
*
11+
* const encoded = Bech32m.encode('tempo', new Uint8Array(20))
12+
* // @log: 'tempo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwa7xtm'
13+
* ```
14+
*
15+
* @param hrp - The human-readable part (e.g. `"tempo"`, `"tempoz"`).
16+
* @param data - The data bytes to encode.
17+
* @returns The bech32m-encoded string.
18+
*/
19+
export function encode(
20+
hrp: string,
21+
data: Uint8Array,
22+
options: encode.Options = {},
23+
): string {
24+
const { limit = 90 } = options
25+
26+
hrp = hrp.toLowerCase()
27+
28+
if (hrp.length === 0) throw new InvalidHrpError()
29+
for (let i = 0; i < hrp.length; i++) {
30+
const c = hrp.charCodeAt(i)
31+
if (c < 33 || c > 126) throw new InvalidHrpError()
32+
}
33+
34+
const data5 = convertBits(data, 8, 5, true)
35+
36+
if (hrp.length + 1 + data5.length + 6 > limit)
37+
throw new ExceedsLengthError({ limit })
38+
39+
const checksum = createChecksum(hrp, data5)
40+
let result = hrp + '1'
41+
for (const d of data5.concat(checksum)) result += alphabet[d]
42+
return result
43+
}
44+
45+
export declare namespace encode {
46+
type Options = {
47+
/** Maximum length of the encoded string. @default 90 */
48+
limit?: number | undefined
49+
}
50+
51+
type ErrorType =
52+
| InvalidHrpError
53+
| ExceedsLengthError
54+
| Errors.GlobalErrorType
55+
}
56+
57+
/**
58+
* Decodes a bech32m string (BIP-350) into a human-readable part and data bytes.
59+
*
60+
* @example
61+
* ```ts twoslash
62+
* import { Bech32m } from 'ox'
63+
*
64+
* const { hrp, data } = Bech32m.decode('tempo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwa7xtm')
65+
* // @log: { hrp: 'tempo', data: Uint8Array(20) }
66+
* ```
67+
*
68+
* @param str - The bech32m-encoded string to decode.
69+
* @returns The decoded HRP and data bytes.
70+
*/
71+
export function decode(
72+
str: string,
73+
options: decode.Options = {},
74+
): decode.ReturnType {
75+
const { limit = 90 } = options
76+
77+
if (str.length > limit) throw new ExceedsLengthError({ limit })
78+
79+
if (str !== str.toLowerCase() && str !== str.toUpperCase())
80+
throw new MixedCaseError()
81+
82+
const lower = str.toLowerCase()
83+
const pos = lower.lastIndexOf('1')
84+
if (pos === -1) throw new NoSeparatorError()
85+
if (pos === 0) throw new InvalidHrpError()
86+
if (pos + 7 > lower.length) throw new InvalidChecksumError()
87+
88+
const hrp = lower.slice(0, pos)
89+
for (let i = 0; i < hrp.length; i++) {
90+
const c = hrp.charCodeAt(i)
91+
if (c < 33 || c > 126) throw new InvalidHrpError()
92+
}
93+
94+
const dataChars = lower.slice(pos + 1)
95+
96+
const data5: number[] = []
97+
for (const c of dataChars) {
98+
const v = alphabetMap[c]
99+
if (v === undefined) throw new InvalidCharacterError({ character: c })
100+
data5.push(v)
101+
}
102+
103+
if (!verifyChecksum(hrp, data5)) throw new InvalidChecksumError()
104+
105+
const data8 = convertBits(data5.slice(0, -6), 5, 8, false)
106+
return { hrp, data: new Uint8Array(data8) }
107+
}
108+
109+
export declare namespace decode {
110+
type Options = {
111+
/** Maximum length of the encoded string. @default 90 */
112+
limit?: number | undefined
113+
}
114+
115+
type ReturnType = {
116+
/** The human-readable part. */
117+
hrp: string
118+
/** The decoded data bytes. */
119+
data: Uint8Array
120+
}
121+
122+
type ErrorType =
123+
| NoSeparatorError
124+
| InvalidChecksumError
125+
| InvalidCharacterError
126+
| InvalidPaddingError
127+
| MixedCaseError
128+
| InvalidHrpError
129+
| ExceedsLengthError
130+
| Errors.GlobalErrorType
131+
}
132+
133+
/** Thrown when a bech32m string has no separator. */
134+
export class NoSeparatorError extends BaseError {
135+
override readonly name = 'Bech32m.NoSeparatorError'
136+
constructor() {
137+
super('Bech32m string has no separator.')
138+
}
139+
}
140+
141+
/** Thrown when a bech32m string has an invalid checksum. */
142+
export class InvalidChecksumError extends BaseError {
143+
override readonly name = 'Bech32m.InvalidChecksumError'
144+
constructor() {
145+
super('Invalid bech32m checksum.')
146+
}
147+
}
148+
149+
/** Thrown when a bech32m string contains an invalid character. */
150+
export class InvalidCharacterError extends BaseError {
151+
override readonly name = 'Bech32m.InvalidCharacterError'
152+
constructor({ character }: { character: string }) {
153+
super(`Invalid bech32m character: "${character}".`)
154+
}
155+
}
156+
157+
/** Thrown when the padding bits are invalid during base32 conversion. */
158+
export class InvalidPaddingError extends BaseError {
159+
override readonly name = 'Bech32m.InvalidPaddingError'
160+
constructor() {
161+
super('Invalid padding in bech32m data.')
162+
}
163+
}
164+
165+
/** Thrown when a bech32m string contains mixed case. */
166+
export class MixedCaseError extends BaseError {
167+
override readonly name = 'Bech32m.MixedCaseError'
168+
constructor() {
169+
super('Bech32m string must not contain mixed case.')
170+
}
171+
}
172+
173+
/** Thrown when the HRP is invalid (empty or contains non-ASCII characters). */
174+
export class InvalidHrpError extends BaseError {
175+
override readonly name = 'Bech32m.InvalidHrpError'
176+
constructor() {
177+
super(
178+
'Invalid bech32m human-readable part (HRP). Must be 1+ characters in ASCII range 33-126.',
179+
)
180+
}
181+
}
182+
183+
/** Thrown when the encoded string exceeds the length limit. */
184+
export class ExceedsLengthError extends BaseError {
185+
override readonly name = 'Bech32m.ExceedsLengthError'
186+
constructor({ limit }: { limit: number }) {
187+
super(`Bech32m string exceeds length limit of ${limit}.`)
188+
}
189+
}
190+
191+
/** @internal */
192+
const alphabet = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
193+
194+
/** @internal */
195+
const alphabetMap = /*#__PURE__*/ (() => {
196+
const map: Record<string, number> = {}
197+
for (let i = 0; i < alphabet.length; i++) map[alphabet[i]!] = i
198+
return map
199+
})()
200+
201+
/** @internal */
202+
const BECH32M_CONST = 0x2bc830a3
203+
204+
/** @internal */
205+
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
206+
207+
/** @internal */
208+
function polymod(values: number[]): number {
209+
let chk = 1
210+
for (const v of values) {
211+
const b = chk >> 25
212+
chk = ((chk & 0x1ffffff) << 5) ^ v
213+
for (let i = 0; i < 5; i++) if ((b >> i) & 1) chk ^= GEN[i]!
214+
}
215+
return chk
216+
}
217+
218+
/** @internal */
219+
function hrpExpand(hrp: string): number[] {
220+
const ret: number[] = []
221+
for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) >> 5)
222+
ret.push(0)
223+
for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) & 31)
224+
return ret
225+
}
226+
227+
/** @internal */
228+
function createChecksum(hrp: string, data: number[]): number[] {
229+
const values = hrpExpand(hrp).concat(data).concat([0, 0, 0, 0, 0, 0])
230+
const mod = polymod(values) ^ BECH32M_CONST
231+
const ret: number[] = []
232+
for (let i = 0; i < 6; i++) ret.push((mod >> (5 * (5 - i))) & 31)
233+
return ret
234+
}
235+
236+
/** @internal */
237+
function verifyChecksum(hrp: string, data: number[]): boolean {
238+
return polymod(hrpExpand(hrp).concat(data)) === BECH32M_CONST
239+
}
240+
241+
/** @internal */
242+
function convertBits(
243+
data: Iterable<number>,
244+
fromBits: number,
245+
toBits: number,
246+
pad: boolean,
247+
): number[] {
248+
let acc = 0
249+
let bits = 0
250+
const maxv = (1 << toBits) - 1
251+
const ret: number[] = []
252+
for (const value of data) {
253+
acc = (acc << fromBits) | value
254+
bits += fromBits
255+
while (bits >= toBits) {
256+
bits -= toBits
257+
ret.push((acc >> bits) & maxv)
258+
}
259+
}
260+
if (pad) {
261+
if (bits > 0) ret.push((acc << (toBits - bits)) & maxv)
262+
} else if (bits >= fromBits || (acc << (toBits - bits)) & maxv) {
263+
throw new InvalidPaddingError()
264+
}
265+
return ret
266+
}

0 commit comments

Comments
 (0)