Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Make Bech32.decode() accept all-numeric input (bugfix)
- Avoid trap in Base58Check.decode() on short input
- Check for invalid input in Wif.decode(), Segwit.encode()
- Make non-trapping:
- Address.addressFromText()
- P2pkh.decodeAddress()
- P2pkh.decodeAddress()
- P2pkh.makeScript()
- Address.scriptPubKey()
- Bitcoin.buildTransaction()
- Wif.decode()

## [0.2.0] - 2026-04-22

### Changed
Expand Down
4 changes: 2 additions & 2 deletions mops.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ keywords = [ "bitcoin" ]
license = "Apache-2.0"

[dependencies]
core = "2.4.0"
core = "2.5.0"
sha2 = "0.1.14"

[dev-dependencies]
Expand All @@ -16,5 +16,5 @@ bench-helper = "0.0.3"

[toolchain]
wasmtime = "43.0.1"
moc = "1.5.0"
moc = "1.6.0"
pocket-ic = "9.0.3"
106 changes: 104 additions & 2 deletions src/Base58.mo
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
/// Base58 encoding and decoding for binary data.
///
/// Base58 is a binary-to-text encoding used in Bitcoin to encode addresses
/// and other data. It uses 58 alphanumeric characters, excluding visually
/// ambiguous characters such as `0`, `I`, `O`, and `l`.
///
/// Import from the bitcoin package to use this module.
/// ```motoko name=import
/// import Base58 "mo:bitcoin/Base58";
/// ```

import Array "mo:core/Array";
import Blob "mo:core/Blob";
import Nat16 "mo:core/Nat16";
Expand Down Expand Up @@ -45,7 +56,88 @@ module {
};
};

// Convert the given Base58 input to Base256.
/// Returns `true` if `input` consists entirely of characters from the
/// Base58 alphabet (`123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz`),
/// optionally surrounded by leading and/or trailing ASCII spaces (`0x20`).
///
/// The empty string and a string of only spaces both return `true`
/// (consistent with `decode`, which accepts these as the encoding of the
/// empty byte array).
///
/// Embedded spaces (between Base58 characters) and any character outside
/// the alphabet (e.g. `'0'`, `'O'`, `'I'`, `'l'`, non-ASCII bytes) cause
/// this function to return `false`.
///
/// Example:
/// ```motoko include=import
/// let ok = Base58.isBase58Alphabet("1AGNa15ZQXAZUgFiqJ2i7Z2DPU2J6hW62i");
/// ```
///
/// Never traps.
public func isBase58Alphabet(input : Text) : Bool {
let bytes : Blob = Text.encodeUtf8(input);
let size = bytes.size();
var pos : Nat = 0;

// Skip leading spaces.
while (pos < size and bytes[pos] == 0x20) {
pos += 1;
};

// Find end of payload (before trailing spaces).
var endPos = size;
while (endPos > pos and bytes[endPos - 1] == 0x20) {
endPos -= 1;
};

// All bytes in [pos, endPos) must map to a Base58 alphabet index.
while (pos < endPos) {
if (mapBase58[bytes[pos].toNat()] == 255) {
return false;
};
pos += 1;
};

true;
};

/// Decodes a Base58-encoded string to a byte array, returning `null`
/// instead of trapping when `input` contains characters outside the
/// Base58 alphabet.
///
/// Equivalent to checking `isBase58Alphabet(input)` first and then
/// calling `decode(input)`. Leading and trailing spaces are accepted (see
/// `decode` for the exact semantics).
///
/// Example:
/// ```motoko include=import
/// let result = Base58.decodeOpt("1AGNa15ZQXAZUgFiqJ2i7Z2DPU2J6hW62i");
/// ```
///
/// Returns `?bytes` on success and `null` if `input` is not valid in the
/// Base58 alphabet. Never traps.
public func decodeOpt(input : Text) : ?[Nat8] {
if (not isBase58Alphabet(input)) {
return null;
};
?decode(input);
};

/// Decodes a Base58-encoded string to a byte array.
///
/// Leading `'1'` characters in the input are preserved as leading zero bytes
/// in the output. Leading and trailing spaces are ignored.
///
/// Example:
/// ```motoko include=import
/// let bytes = Base58.decode("1AGNa15ZQXAZUgFiqJ2i7Z2DPU2J6hW62i");
/// ```
///
/// Traps if `input_` contains any byte that is not a leading space, a
/// trailing space, or a character in the Base58 alphabet
/// (`123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz`). In
/// particular, spaces embedded inside the payload, ASCII characters such
/// as `'0'`, `'O'`, `'I'`, `'l'`, and any non-ASCII byte all trap.
public func decode(input_ : Text) : [Nat8] {
let input : Blob = Text.encodeUtf8(input_);
let inputSize = input.size();
Expand Down Expand Up @@ -154,7 +246,17 @@ module {
);
};

// Convert the given Base256 input to Base58.
/// Encodes a byte array as a Base58 string.
///
/// Leading zero bytes in the input are preserved as leading `'1'` characters
/// in the output.
///
/// Example:
/// ```motoko include=import
/// let encoded = Base58.encode([0x00, 0x01, 0x02]);
/// ```
///
/// Never traps. Accepts any byte array, including the empty array.
public func encode(input : [Nat8]) : Text {
let inputSize = input.size();
var length : Nat = 0;
Expand Down
48 changes: 45 additions & 3 deletions src/Base58Check.mo
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
/// Base58Check encoding and decoding for binary data.
///
/// Base58Check extends Base58 by appending a 4-byte checksum derived from
/// a double SHA-256 hash of the payload. This detects transcription errors
/// when users copy Bitcoin addresses.
///
/// Import from the bitcoin package to use this module.
/// ```motoko name=import
/// import Base58Check "mo:bitcoin/Base58Check";
/// ```

import Array "mo:core/Array";
import Blob "mo:core/Blob";
import Nat "mo:core/Nat";
Expand All @@ -9,7 +20,14 @@ import Base58 "Base58";

module {

// Convert the given Base256 input to Base58 with checksum.
/// Encodes a byte array to a Base58Check string by appending a 4-byte checksum.
///
/// Example:
/// ```motoko include=import
/// let encoded = Base58Check.encode([0x00, 0x01, 0x02]);
/// ```
///
/// Never traps. Accepts any byte array, including the empty array.
public func encode(input : [Nat8]) : Text {
// Add 4-byte hash check to the end.
let hash : [Nat8] = Sha256.fromBlob(#sha256, Sha256.fromArray(#sha256, input)).toArray();
Expand All @@ -27,11 +45,35 @@ module {
Base58.encode(inputWithCheck.toArray());
};

// Convert the given checked Base58 input to Base256. Returns null if the
// checksum verification fails.
/// Decodes a Base58Check string, verifying the embedded 4-byte checksum.
///
/// On success, returns `?payload` where `payload` is the original byte
/// array passed to `encode` (i.e. with the 4 checksum bytes already
/// stripped). The first byte is conventionally a Bitcoin version byte
/// (e.g. `0x00` for mainnet P2PKH, `0x6f` for testnet P2PKH, `0x80` for
/// mainnet WIF) but this function does not interpret it.
///
/// Example:
/// ```motoko include=import
/// let decoded = Base58Check.decode("1AGNa15ZQXAZUgFiqJ2i7Z2DPU2J6hW62i");
/// ```
///
/// Returns `null` when:
/// - the Base58 decoding of `input` produces fewer than 4 bytes (so there
/// is no room for the 4-byte checksum), or
/// - the trailing 4 checksum bytes do not match the double-SHA256 of the
/// payload.
///
/// Traps if `input` contains any character outside the Base58 alphabet
/// (propagated from `Base58.decode`). For fully graceful parsing of
/// arbitrary user input, validate the character set first.
public func decode(input : Text) : ?[Nat8] {
let decoded : [Nat8] = Base58.decode(input);

if (decoded.size() < 4) {
return null;
};

// Strip the last 4 bytes.
let output = Array.tabulate<Nat8>(
decoded.size() - 4,
Expand Down
69 changes: 68 additions & 1 deletion src/Bech32.mo
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
/// Bech32 and Bech32m encoding and decoding.
///
/// Bech32 is a segwit address format defined in
/// [BIP173](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki).
/// Bech32m is a modification defined in
/// [BIP350](https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki)
/// used for native segwit version 1 (Taproot) and later addresses.
///
/// Import from the bitcoin package to use this module.
/// ```motoko name=import
/// import Bech32 "mo:bitcoin/Bech32";
/// ```

import Array "mo:core/Array";
import Blob "mo:core/Blob";
import Nat "mo:core/Nat";
Expand All @@ -10,11 +23,19 @@ import { type Result } "mo:core/Types";
import VarArray "mo:core/VarArray";

module {
/// The Bech32 encoding variant to use.
///
/// - `#BECH32`: Original Bech32 encoding (BIP173), used for segwit version 0.
/// - `#BECH32M`: Bech32m encoding (BIP350), used for segwit version 1+.
public type Encoding = {
#BECH32;
#BECH32M;
};

/// The result of a successful Bech32 decode operation.
///
/// Contains the encoding type, the human-readable part (HRP), and the data
/// payload as a byte array.
// A decoded result contains Encoding type, human-readable part (HRP), and Data.
public type DecodeResult = (Encoding, Text, [Nat8]);

Expand Down Expand Up @@ -56,6 +77,26 @@ module {
};
};

/// Encodes data as a Bech32 or Bech32m string.
///
/// `hrp` is the human-readable part (must be lowercase ASCII in the range
/// `'!'`..`'~'` and non-empty). `values` is the data payload encoded as
/// 5-bit groups (each value must be in `[0, 31]`). `encoding` selects
/// between `#BECH32` (BIP173, segwit v0) and `#BECH32M` (BIP350,
/// segwit v1+).
///
/// Example:
/// ```motoko include=import
/// let encoded = Bech32.encode("bc", [0, 1, 2], #BECH32M);
/// ```
///
/// Traps if any of the following hold:
/// - `hrp` is empty.
/// - `hrp` contains a character outside the printable ASCII range
/// `'!'`..`'~'`, or contains an uppercase letter `'A'`..`'Z'`.
/// - Any byte in `values` is greater than `31` (out-of-bounds index into
/// the Bech32 charset).
/// - The encoded output would exceed the 90-character Bech32 limit.
// Encode input in Bech32 or a Bech32m.
public func encode(hrp : Text, values : [Nat8], encoding : Encoding) : Text {
assert hrp.size() > 0;
Expand All @@ -82,6 +123,32 @@ module {
arrayToText(output);
};

/// Decodes a Bech32 or Bech32m encoded string.
///
/// Returns `#ok((encoding, hrp, data))` on success. The `hrp` in the
/// result is normalized to lowercase and `data` is the 5-bit-group
/// payload (excluding checksum).
///
/// Example:
/// ```motoko include=import
/// let result = Bech32.decode("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
/// ```
///
/// Never traps. Returns `#err(message)` for every malformed input. The
/// distinct error categories are:
/// - `"Found unexpected character: ..."` — `input` contains a byte
/// outside the printable ASCII range `'!'`..`'~'`.
/// - `"Inconsistent character casing in HRP."` — `input` contains both
/// lowercase and uppercase alphabetic characters (forbidden by Bech32
/// standard).
/// - `"Bad separator position: ..."` — the `'1'` separator is missing,
/// too close to the start, or leaves fewer than 6 checksum characters
/// at the end; or the total length exceeds 90 characters.
/// - `"Invalid character found: ..."` — a character in the data section
/// is not part of the Bech32 charset.
/// - `"Checksum verification failed."` — the polymod checksum (computed
/// for both BECH32 and BECH32M) does not match either variant.
/// - `"Failed to decode HRP."` — the HRP bytes are not valid UTF-8.
// Decode given text as Bech32 or Bech32m.
public func decode(input : Text) : Result<DecodeResult, Text> {
// Locate the '1' separator.
Expand All @@ -107,7 +174,7 @@ module {

};

if (lowercase == uppercase) {
if (lowercase and uppercase) {
return #err("Inconsistent character casing in HRP.");
};

Expand Down
Loading
Loading