From 0db2124f2e7b238f63642950485787bb9d358628 Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Thu, 23 Apr 2026 11:06:44 +0200 Subject: [PATCH 01/17] Add doc strings by Opus 4.7 --- mops.toml | 2 +- src/Base58.mo | 39 ++++++++++++- src/Base58Check.mo | 43 +++++++++++++- src/Bech32.mo | 66 +++++++++++++++++++++ src/Bip32.mo | 114 ++++++++++++++++++++++++++++++++++++- src/ByteUtils.mo | 54 ++++++++++++++++++ src/Common.mo | 112 ++++++++++++++++++++++++++++++++++++ src/Hash.mo | 43 ++++++++++++++ src/Hmac.mo | 55 ++++++++++++++++++ src/Ripemd160.mo | 38 +++++++++++++ src/Segwit.mo | 60 +++++++++++++++++++ src/bitcoin/Address.mo | 38 ++++++++++++- src/bitcoin/Bitcoin.mo | 60 +++++++++++++++++-- src/bitcoin/P2pkh.mo | 41 ++++++++++++- src/bitcoin/P2tr.mo | 34 ++++++++++- src/bitcoin/Script.mo | 34 +++++++++++ src/bitcoin/Transaction.mo | 73 ++++++++++++++++++++++-- src/bitcoin/TxInput.mo | 31 ++++++++++ src/bitcoin/TxOutput.mo | 19 +++++++ src/bitcoin/Types.mo | 44 ++++++++++++++ src/bitcoin/Wif.mo | 25 ++++++++ src/bitcoin/Witness.mo | 19 +++++++ src/ec/Affine.mo | 34 ++++++++++- src/ec/Curves.mo | 16 ++++++ src/ec/Field.mo | 26 +++++++++ src/ec/Fp.mo | 34 +++++++++++ src/ec/Jacobi.mo | 43 +++++++++----- src/ec/Numbers.mo | 24 ++++++++ src/ecdsa/Der.mo | 30 ++++++++++ src/ecdsa/Ecdsa.mo | 19 +++++++ src/ecdsa/Publickey.mo | 25 +++++++- src/ecdsa/Types.mo | 24 ++++++++ 32 files changed, 1277 insertions(+), 42 deletions(-) diff --git a/mops.toml b/mops.toml index 1887ef7..9997099 100644 --- a/mops.toml +++ b/mops.toml @@ -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" diff --git a/src/Base58.mo b/src/Base58.mo index 55674d5..ce292bf 100755 --- a/src/Base58.mo +++ b/src/Base58.mo @@ -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"; @@ -45,7 +56,21 @@ module { }; }; - // Convert the given Base58 input to Base256. + /// 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(); @@ -154,7 +179,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; diff --git a/src/Base58Check.mo b/src/Base58Check.mo index 81e389c..4b71571 100755 --- a/src/Base58Check.mo +++ b/src/Base58Check.mo @@ -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"; @@ -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(); @@ -27,8 +45,27 @@ 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 trailing 4 checksum bytes do not match the + /// double-SHA256 of the payload. + /// + /// Traps if the Base58 decoding of `input` produces fewer than 4 bytes + /// (via `Nat` underflow when stripping the checksum) or 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); diff --git a/src/Bech32.mo b/src/Bech32.mo index 293f211..aa3dc89 100644 --- a/src/Bech32.mo +++ b/src/Bech32.mo @@ -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"; @@ -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]); @@ -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; @@ -82,6 +123,31 @@ 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` mixes upper- + /// and lowercase letters (Bech32 forbids mixed case). + /// - `"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 { // Locate the '1' separator. diff --git a/src/Bip32.mo b/src/Bip32.mo index 10e7c06..4e2d4ea 100644 --- a/src/Bip32.mo +++ b/src/Bip32.mo @@ -1,3 +1,13 @@ +/// BIP32 extended public key parsing, derivation, and serialization. +/// +/// Provides utilities to parse `xpub` strings, derive non-hardened child keys, +/// and serialize extended public keys. +/// +/// Import from the bitcoin package to use this module. +/// ```motoko name=import +/// import Bip32 "mo:bitcoin/Bip32"; +/// ``` + import Array "mo:core/Array"; import Blob "mo:core/Blob"; import Iter "mo:core/Iter"; @@ -16,13 +26,19 @@ import Hmac "Hmac"; import Jacobi "ec/Jacobi"; module { + /// Child derivation path representation. + /// + /// `#text` expects a string such as `"m/0/1"`. + /// `#array` uses already parsed child indices. public type Path = { #text : Text; #array : [Nat32]; }; - // #publicKeyData is SEC1 encoded public key data. - // #fingerprint is the fingerprint of the public key. + /// Optional parent public key information used for fingerprint validation. + /// + /// `#publicKeyData` is a compressed SEC1 public key. + /// `#fingerprint` is a 4-byte HASH160 fingerprint. public type ParentPublicKey = { #publicKeyData : [Nat8]; #fingerprint : [Nat8]; @@ -31,6 +47,32 @@ module { let curve : Curves.Curve = Curves.secp256k1; let publicPrefix : Nat32 = 0x0488B21E; + /// Parses a Base58Check-encoded BIP32 extended public key. + /// + /// If `_parentPubKey` is `#publicKeyData`, validates that its fingerprint + /// matches the parsed key. If `_parentPubKey` is non-empty `#fingerprint`, + /// validates against that fingerprint. For empty `#fingerprint`, the parsed + /// fingerprint is inherited. + /// + /// Example: + /// ```motoko include=import + /// let parsed = Bip32.parse(xpub, null); + /// ``` + /// + /// Returns `null` when: + /// - `bip32Key` is not valid Base58Check (alphabet error or checksum + /// mismatch). + /// - The version prefix is not `0x0488B21E` (mainnet `xpub`). + /// - At depth 0 (master key) the parent fingerprint is non-zero, the + /// index is non-zero, or a non-`null` `_parentPubKey` is provided. + /// - At depth > 0 the parsed fingerprint does not match the supplied + /// `_parentPubKey` data or fingerprint, or `_parentPubKey` is `null`. + /// - The compressed key prefix byte is not `0x02` or `0x03`. + /// - The 33-byte key does not decode to a point on the secp256k1 curve. + /// + /// Traps if the Base58Check payload is shorter than 78 bytes (out-of-bounds + /// access on the fixed-offset reads of version, depth, fingerprint, index, + /// chaincode, and key). // Parse a Bip32 serialized key. If _parentPubKey is #publicKeyData, will // verify the fingerprint within the parsed key. If _parentPubKey is // non-empty #fingerprint, will verify the fingerprint against those values. @@ -135,6 +177,21 @@ module { index >= 0x80000000 // 2**31 }; + /// Parses a textual path like `"m/0/1/2"` into child indices. + /// + /// Whitespace and a leading `"m/"` prefix are stripped. Each remaining + /// `'/'`-separated segment must be a non-negative decimal integer in the + /// `Nat32` range. + /// + /// Example: + /// ```motoko include=import + /// let path = Bip32.arrayPathFromString("m/0/1/2"); + /// ``` + /// + /// Never traps. Returns `null` when: + /// - any segment is not a valid decimal `Nat`, + /// - any segment is `≥ 2^32`, + /// - hardened markers (e.g. `m/0'`) are present (parsed as non-numeric). // Parses a Text path in the form "m/a/b/c/..." for unsigned integers // a,b,c,... and returns an array [a, b, c, ...]. Parsing fails and returns // null if input is not in the expected format or if it contains hardened @@ -180,6 +237,30 @@ module { if (valid) res else null; }; + /// A BIP32 extended public key. + /// + /// Supports non-hardened child derivation and Base58Check serialization. + /// + /// Constructor arguments: + /// - `_key` — 33-byte compressed SEC1 public key (`0x02` or `0x03` + /// prefix followed by the 32-byte x coordinate). + /// - `_chaincode` — 32-byte chain code from the BIP32 derivation. + /// - `_depth` — derivation depth (`0` for the master key). + /// - `_index` — child index relative to the parent (`0` at depth `0`). + /// Indices `< 2^31` are non-hardened; indices `>= 2^31` are hardened + /// and cannot be derived from a public key alone. + /// - `_parentPublicKey` — parent identification: `null` at depth `0`, + /// `?#publicKeyData` for the full 33-byte compressed parent key, or + /// `?#fingerprint` for just the 4-byte HASH160 fingerprint. + /// + /// Calls to `serialize` and `deriveChild` rely on these sizes; passing + /// shorter arrays will cause out-of-bounds traps later. + /// + /// Example: + /// ```motoko include=import + /// let xpub = Bip32.ExtendedPublicKey(key, chaincode, 0, 0, null); + /// let child = xpub.deriveChild(0); + /// ``` // Representation of a BIP32 extended public key. public class ExtendedPublicKey( _key : [Nat8], @@ -189,12 +270,23 @@ module { _parentPublicKey : ?ParentPublicKey, ) { + /// Compressed SEC1 public key bytes. public let key = _key; + /// Chain code for child derivation. public let chaincode = _chaincode; + /// Depth in the derivation tree (`0` for master). public let depth = _depth; + /// Child index relative to parent. public let index = _index; + /// Optional parent key data or fingerprint. public let parentPublicKey = _parentPublicKey; + /// Derives a descendant key by applying all indices in `path`. + /// + /// Never traps. Returns `null` when: + /// - `path` is `#text` and `arrayPathFromString` rejects it (see that + /// function for the exact rules), or + /// - any individual `deriveChild` step fails (see `deriveChild`). // Derive a child public key with path relative to this instance. Returns // null if path is #text and cannot be parsed. public func derivePath(path : Path) : ?ExtendedPublicKey { @@ -225,6 +317,16 @@ module { }; }; + /// Derives a non-hardened child key at `index`. + /// + /// Never traps. Returns `null` when: + /// - `index >= 2^31` (hardened indices cannot be derived from a public + /// key alone), + /// - the HMAC-SHA512 left half is `≥` the secp256k1 group order (probability + /// under `2^-127`), + /// - the parent key bytes do not represent a valid secp256k1 point, or + /// - the resulting child point is the point at infinity (probability + /// under `2^-127`). // Derive child at the given index. Valid indices are in the range // [0, 2^31 - 1] and the function throws an error if given an index outside // this range. @@ -289,6 +391,14 @@ module { }; }; + /// Serializes this key to an `xpub` Base58Check string. + /// + /// The output follows the BIP32 extended public key wire format. + /// + /// Never traps when the instance was constructed with the canonical + /// `33`-byte compressed key, `32`-byte chaincode, and `4`-byte + /// fingerprint expected by BIP32. Constructing the instance with shorter + /// arrays would cause out-of-bounds traps here. // Serialize the extended public key data into Base58 encoded string // following format dictated by BIP32 specification. public func serialize() : Text { diff --git a/src/ByteUtils.mo b/src/ByteUtils.mo index e6af3a1..d6f35bd 100644 --- a/src/ByteUtils.mo +++ b/src/ByteUtils.mo @@ -1,3 +1,14 @@ +/// Utilities for reading and writing Bitcoin-serialized binary data. +/// +/// Provides functions to read integers and variable-length data from +/// iterators, and to encode values using Bitcoin's variable-length integer +/// (varint) format. +/// +/// Import from the bitcoin package to use this module. +/// ```motoko name=import +/// import ByteUtils "mo:bitcoin/ByteUtils"; +/// ``` + import Nat16 "mo:core/Nat16"; import Nat32 "mo:core/Nat32"; import Nat64 "mo:core/Nat64"; @@ -8,6 +19,18 @@ import VarArray "mo:core/VarArray"; import Common "Common"; module { + /// Reads `count` bytes from `data`, returning them as an array. + /// + /// If `reverse` is `true`, the bytes are stored in reverse order. + /// + /// Example: + /// ```motoko include=import + /// let bytes = ByteUtils.read([1, 2, 3, 4].values(), 3, false); + /// ``` + /// + /// Never traps. Returns `null` if `data` is exhausted before `count` + /// bytes have been read. When `count == 0`, returns `?[]` immediately + /// without consuming any input. // Read a number of elements from the given iterator and return as array. If // reverse is true, will read return the elements in reverse order. // Returns null if the iterator does not produce enough data. @@ -43,6 +66,9 @@ module { }; }; + /// Reads a 16-bit unsigned integer in little-endian byte order from `data`. + /// + /// Never traps. Returns `null` if `data` does not yield at least 2 bytes. // Read little endian 16-bit natural number starting at offset. // Returns null if the iterator does not produce enough data. public func readLE16(data : Iter) : ?Nat16 { @@ -52,6 +78,9 @@ module { }; }; + /// Reads a 32-bit unsigned integer in little-endian byte order from `data`. + /// + /// Never traps. Returns `null` if `data` does not yield at least 4 bytes. // Read little endian 32-bit natural number starting at offset. // Returns null if the iterator does not produce enough data. public func readLE32(data : Iter) : ?Nat32 { @@ -61,6 +90,9 @@ module { }; }; + /// Reads a 64-bit unsigned integer in little-endian byte order from `data`. + /// + /// Never traps. Returns `null` if `data` does not yield at least 8 bytes. // Read little endian 64-bit natural number starting at offset. // Returns null if the iterator does not produce enough data. public func readLE64(data : Iter) : ?Nat64 { @@ -80,12 +112,23 @@ module { }; }; + /// Reads a single byte from `data`. + /// + /// Never traps. Returns `null` if `data` is exhausted. // Read one element from the given iterator. // Returns null if the iterator does not produce enough data. public func readOne(data : Iter) : ?Nat8 { data.next(); }; + /// Reads a Bitcoin variable-length integer (varint) from `data`. + /// + /// Varints encode values in 1 byte (`< 0xfd`), 3 bytes (prefix `0xfd`, + /// `Nat16` LE), 5 bytes (prefix `0xfe`, `Nat32` LE), or 9 bytes + /// (prefix `0xff`, `Nat64` LE). + /// + /// Never traps. Returns `null` when `data` is exhausted before the + /// expected payload bytes have been read for the chosen prefix. // Read and return a varint encoded integer from data. // Returns null if the iterator does not produce enough data. public func readVarint(data : Iter) : ?Nat { @@ -107,6 +150,17 @@ module { }; }; + /// Encodes `value` as a Bitcoin variable-length integer (varint). + /// + /// Returns a 1-, 3-, 5-, or 9-byte array depending on the magnitude of `value`. + /// + /// Example: + /// ```motoko include=import + /// let encoded = ByteUtils.writeVarint(252); // [0xfc] + /// let encoded2 = ByteUtils.writeVarint(253); // [0xfd, 0xfd, 0x00] + /// ``` + /// + /// Traps if `value >= 2^64` (the largest representable varint). // Encode value as varint. public func writeVarint(value : Nat) : [Nat8] { assert (value < 0x10000000000000000); diff --git a/src/Common.mo b/src/Common.mo index 1dd3e79..dfd996d 100644 --- a/src/Common.mo +++ b/src/Common.mo @@ -1,3 +1,20 @@ +/// Low-level binary read/write utilities for big-endian and little-endian integers. +/// +/// Provides helpers to read and write multi-byte integers from and to byte +/// arrays at a given offset. Used throughout the Bitcoin protocol for +/// serializing and deserializing data structures. +/// +/// **Bounds:** every function in this module performs raw indexed access at +/// the supplied `offset` and traps if `offset + N > bytes.size()`, where `N` +/// is the integer width in bytes (4 for `*32`, 8 for `*64`, 16 for `*128`, +/// 32 for `*256`, 2 for `writeLE16`, and `count` for `copy`). Callers are +/// responsible for sizing the buffer correctly. +/// +/// Import from the bitcoin package to use this module. +/// ```motoko name=import +/// import Common "mo:bitcoin/Common"; +/// ``` + import Nat "mo:core/Nat"; import Nat16 "mo:core/Nat16"; import Nat32 "mo:core/Nat32"; @@ -5,11 +22,30 @@ import Nat64 "mo:core/Nat64"; import Nat8 "mo:core/Nat8"; module { + /// Reads a 32-bit unsigned integer in big-endian byte order from `bytes` starting at `offset`. + /// + /// Example: + /// ```motoko include=import + /// let value = Common.readBE32([0x01, 0x02, 0x03, 0x04], 0); + /// assert value == 0x01020304; + /// ``` + /// + /// Traps if `offset + 4 > bytes.size()`. // Read big endian 32-bit natural number starting at offset. public func readBE32(bytes : [Nat8], offset : Nat) : Nat32 { bytes[offset + 0].toNat16().toNat32() << 24 | bytes[offset + 1].toNat16().toNat32() << 16 | bytes[offset + 2].toNat16().toNat32() << 8 | bytes[offset + 3].toNat16().toNat32(); }; + /// Reads a 64-bit unsigned integer in big-endian byte order from `bytes` starting at `offset`. + /// + /// Example: + /// ```motoko include=import + /// let buf : [Nat8] = [0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04]; + /// let value = Common.readBE64(buf, 0); + /// assert value == 0x0000000001020304; + /// ``` + /// + /// Traps if `offset + 8 > bytes.size()`. // Read big endian 64-bit natural number starting at offset. public func readBE64(bytes : [Nat8], offset : Nat) : Nat64 { let first : Nat32 = readBE32(bytes, offset); @@ -18,6 +54,14 @@ module { first.toNat64() << 32 | second.toNat64(); }; + /// Reads a 128-bit unsigned integer in big-endian byte order from `bytes` starting at `offset`. + /// + /// Example: + /// ```motoko include=import + /// let value = Common.readBE128([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04], 0); + /// ``` + /// + /// Traps if `offset + 16 > bytes.size()`. // Read big endian 128-bit natural number starting at offset. public func readBE128(bytes : [Nat8], offset : Nat) : Nat { let first : Nat64 = readBE64(bytes, offset); @@ -26,6 +70,17 @@ module { first.toNat() * 0x10000000000000000 + second.toNat(); }; + /// Reads a 256-bit unsigned integer in big-endian byte order from `bytes` starting at `offset`. + /// + /// Used for reading elliptic curve scalars and coordinates. + /// + /// Example: + /// ```motoko include=import + /// // Read a 256-bit value from a 32-byte array + /// // let value = Common.readBE256(bytes32, 0); + /// ``` + /// + /// Traps if `offset + 32 > bytes.size()`. // Read big endian 256-bit natural number starting at offset. public func readBE256(bytes : [Nat8], offset : Nat) : Nat { let first : Nat = readBE128(bytes, offset); @@ -34,6 +89,17 @@ module { first * 0x100000000000000000000000000000000 + second; }; + /// Writes `value` as a 32-bit big-endian integer into `bytes` at `offset`. + /// + /// Example: + /// ```motoko include=import + /// import VarArray "mo:core/VarArray"; + /// let buf = VarArray.repeat(0, 4); + /// Common.writeBE32(buf, 0, 0x01020304); + /// assert buf[0] == 0x01; + /// ``` + /// + /// Traps if `offset + 4 > bytes.size()`. // Write given value as 32-bit big endian into array starting at offset. public func writeBE32(bytes : [var Nat8], offset : Nat, value : Nat32) { bytes[offset] := Nat8.fromNat(((value & 0xFF000000) >> 24).toNat()); @@ -42,6 +108,16 @@ module { bytes[offset + 3] := Nat8.fromNat((value & 0xFF).toNat()); }; + /// Writes `value` as a 64-bit big-endian integer into `bytes` at `offset`. + /// + /// Example: + /// ```motoko include=import + /// import VarArray "mo:core/VarArray"; + /// let buf = VarArray.repeat(0, 8); + /// Common.writeBE64(buf, 0, 0x0102030405060708); + /// ``` + /// + /// Traps if `offset + 8 > bytes.size()`. // Write given value as 64-bit big endian into array starting at offset. public func writeBE64(bytes : [var Nat8], offset : Nat, value : Nat64) { let first : Nat32 = Nat32.fromIntWrap((value >> 32).toNat()); @@ -51,6 +127,9 @@ module { writeBE32(bytes, offset + 4, second); }; + /// Writes `value` as a 128-bit big-endian integer into `bytes` at `offset`. + /// + /// Traps if `offset + 16 > bytes.size()`. // Write given value as 128-bit big endian into array starting at offset. public func writeBE128(bytes : [var Nat8], offset : Nat, value : Nat) { let first : Nat64 = Nat64.fromIntWrap(value / 0x10000000000000000); @@ -60,6 +139,9 @@ module { writeBE64(bytes, offset + 8, second); }; + /// Writes `value` as a 256-bit big-endian integer into `bytes` at `offset`. + /// + /// Traps if `offset + 32 > bytes.size()`. // Write given value as 256-bit big endian into array starting at offset. public func writeBE256(bytes : [var Nat8], offset : Nat, value : Nat) { let first : Nat = value / (2 ** 128); @@ -69,11 +151,23 @@ module { writeBE128(bytes, offset + 16, second); }; + /// Reads a 32-bit unsigned integer in little-endian byte order from `bytes` starting at `offset`. + /// + /// Example: + /// ```motoko include=import + /// let value = Common.readLE32([0x04, 0x03, 0x02, 0x01], 0); + /// assert value == 0x01020304; + /// ``` + /// + /// Traps if `offset + 4 > bytes.size()`. // Read little endian 32-bit natural number starting at offset. public func readLE32(bytes : [Nat8], offset : Nat) : Nat32 { bytes[offset + 3].toNat16().toNat32() << 24 | bytes[offset + 2].toNat16().toNat32() << 16 | bytes[offset + 1].toNat16().toNat32() << 8 | bytes[offset + 0].toNat16().toNat32(); }; + /// Writes `value` as a 16-bit little-endian integer into `bytes` at `offset`. + /// + /// Traps if `offset + 2 > bytes.size()`. // Write given value as 16-bit little endian into array starting at offset. public func writeLE16(bytes : [var Nat8], offset : Nat, value : Nat16) { let first : Nat8 = Nat8.fromIntWrap(value.toNat()); @@ -83,6 +177,17 @@ module { bytes[offset + 1] := second; }; + /// Writes `value` as a 32-bit little-endian integer into `bytes` at `offset`. + /// + /// Example: + /// ```motoko include=import + /// import VarArray "mo:core/VarArray"; + /// let buf = VarArray.repeat(0, 4); + /// Common.writeLE32(buf, 0, 0x01020304); + /// assert buf[0] == 0x04; + /// ``` + /// + /// Traps if `offset + 4 > bytes.size()`. // Write given value as 32-bit little endian into array starting at offset. public func writeLE32(bytes : [var Nat8], offset : Nat, value : Nat32) { let first : Nat16 = Nat16.fromIntWrap(value.toNat()); @@ -92,6 +197,9 @@ module { writeLE16(bytes, offset + 2, second); }; + /// Writes `value` as a 64-bit little-endian integer into `bytes` at `offset`. + /// + /// Traps if `offset + 8 > bytes.size()`. // Write given value as 64-bit little endian into array starting at offset. public func writeLE64(bytes : [var Nat8], offset : Nat, value : Nat64) { let first : Nat32 = Nat32.fromIntWrap(value.toNat()); @@ -101,6 +209,10 @@ module { writeLE32(bytes, offset + 4, second); }; + /// Copies `count` bytes from `src` starting at `srcOffset` into `dest` starting at `destOffset`. + /// + /// Traps if `destOffset + count > dest.size()` or + /// `srcOffset + count > src.size()`. // Copy data from src into dest from/at the given offsets. public func copy( dest : [var Nat8], diff --git a/src/Hash.mo b/src/Hash.mo index 8a16993..82a004d 100644 --- a/src/Hash.mo +++ b/src/Hash.mo @@ -1,3 +1,13 @@ +/// Common Bitcoin hash functions. +/// +/// Provides SHA256, RIPEMD160, and Bitcoin-specific hash operations +/// used throughout the Bitcoin protocol. +/// +/// Import from the bitcoin package to use this module. +/// ```motoko name=import +/// import Hash "mo:bitcoin/Hash"; +/// ``` + import Array "mo:core/Array"; import Blob "mo:core/Blob"; import Text "mo:core/Text"; @@ -7,16 +17,49 @@ import Sha256 "mo:sha2/Sha256"; import Ripemd160 "Ripemd160"; module { + /// Applies SHA-256 followed by RIPEMD-160 to the given data. + /// + /// This is the `HASH160` operation used in Bitcoin scripts. + /// + /// Example: + /// ```motoko include=import + /// let hash = Hash.hash160([0x01, 0x02, 0x03]); + /// ``` + /// + /// Never traps. Always returns a 20-byte digest. // Applies SHA256 followed by RIPEMD160 on the given data. public func hash160(data : [Nat8]) : [Nat8] { Ripemd160.hash(Sha256.fromArray(#sha256, data).toArray()); }; + /// Applies double SHA-256 (SHA256d) to the given data. + /// + /// This is used in Bitcoin for computing transaction IDs, block hashes, + /// and other values throughout the protocol. + /// + /// Example: + /// ```motoko include=import + /// let hash = Hash.doubleSHA256([0x01, 0x02, 0x03]); + /// ``` + /// + /// Never traps. Always returns a 32-byte digest. // Applies double SHA256 to input. public func doubleSHA256(data : [Nat8]) : [Nat8] { Sha256.fromBlob(#sha256, Sha256.fromArray(#sha256, data)).toArray(); }; + /// Computes a tagged hash as defined in BIP-340. + /// + /// The tagged hash is `SHA256(SHA256(tag) || SHA256(tag) || data)`, where + /// `tag` is a UTF-8 encoded domain-separation string. This is used in + /// Taproot to prevent hash collisions across different protocol contexts. + /// + /// Example: + /// ```motoko include=import + /// let hash = Hash.taggedHash([0x01, 0x02], "TapTweak"); + /// ``` + /// + /// Never traps. Always returns a 32-byte digest. public func taggedHash(data : [Nat8], tag : Text) : [Nat8] { let tag_hash = Sha256.fromBlob(#sha256, tag.encodeUtf8()).toArray(); Sha256.fromArray(#sha256, [tag_hash, tag_hash, data].flatten()).toArray(); diff --git a/src/Hmac.mo b/src/Hmac.mo index 2f2f1b8..aba626d 100644 --- a/src/Hmac.mo +++ b/src/Hmac.mo @@ -1,3 +1,13 @@ +/// HMAC (Hash-based Message Authentication Code) implementation. +/// +/// Supports HMAC-SHA256 and HMAC-SHA512, used in BIP32 key derivation +/// and other Bitcoin cryptographic operations. +/// +/// Import from the bitcoin package to use this module. +/// ```motoko name=import +/// import Hmac "mo:bitcoin/Hmac"; +/// ``` + import Array "mo:core/Array"; import Blob "mo:core/Blob"; @@ -5,21 +15,42 @@ import Sha256 "mo:sha2/Sha256"; import Sha512 "mo:sha2/Sha512"; module { + /// Interface for an incremental hash digest. + /// + /// Allows writing data in chunks and retrieving the final hash. public type Digest = { writeArray : ([Nat8]) -> (); sum : () -> Blob; }; + /// Factory for creating `Digest` instances of a specific hash function. + /// + /// `blockSize` is the internal block size in bytes (64 for SHA256, 128 for SHA512). + /// `create` returns a fresh `Digest` instance. public type DigestFactory = { blockSize : Nat; create : () -> Digest; }; + /// Interface for computing an incremental HMAC. + /// + /// Allows writing data in chunks and retrieving the final HMAC. public type Hmac = { writeArray : ([Nat8]) -> (); sum : () -> Blob; }; + /// Creates an HMAC-SHA256 instance with the given `key`. + /// + /// Example: + /// ```motoko include=import + /// let hmac = Hmac.sha256([0x01, 0x02, 0x03]); + /// hmac.writeArray([0x04, 0x05]); + /// let result = hmac.sum(); + /// ``` + /// + /// Never traps. Accepts a `key` of any length, including the empty key. + /// Subsequent `writeArray` and `sum` calls also never trap. // Sha256 support. object sha256DigestFactory { public let blockSize : Nat = 64; @@ -27,6 +58,17 @@ module { }; public func sha256(key : [Nat8]) : Hmac = HmacImpl(key, sha256DigestFactory); + /// Creates an HMAC-SHA512 instance with the given `key`. + /// + /// Example: + /// ```motoko include=import + /// let hmac = Hmac.sha512([0x01, 0x02, 0x03]); + /// hmac.writeArray([0x04, 0x05]); + /// let result = hmac.sum(); + /// ``` + /// + /// Never traps. Accepts a `key` of any length, including the empty key. + /// Subsequent `writeArray` and `sum` calls also never trap. // Sha512 support. object sha512DigestFactory { public let blockSize : Nat = 128; @@ -34,6 +76,19 @@ module { }; public func sha512(key : [Nat8]) : Hmac = HmacImpl(key, sha512DigestFactory); + /// Creates an HMAC instance using a custom digest factory. + /// + /// Use this when neither SHA256 nor SHA512 matches your needs. + /// + /// Example: + /// ```motoko include=import + /// // Use a custom digest factory + /// // let hmac = Hmac.new(key, myFactory); + /// ``` + /// + /// Never traps as long as the supplied `digestFactory` itself is total. + /// Trap behavior of `writeArray` and `sum` on the returned instance is + /// inherited from the digest implementation. // Construct HMAC from an arbitrary digest function. public func new(key : [Nat8], digestFactory : DigestFactory) : Hmac { HmacImpl(key, digestFactory); diff --git a/src/Ripemd160.mo b/src/Ripemd160.mo index ef14479..49b9aa2 100644 --- a/src/Ripemd160.mo +++ b/src/Ripemd160.mo @@ -1,3 +1,13 @@ +/// RIPEMD-160 hash implementation. +/// +/// Provides a one-shot hash function and an incremental digest API. +/// RIPEMD-160 is used in Bitcoin as part of HASH160. +/// +/// Import from the bitcoin package to use this module. +/// ```motoko name=import +/// import Ripemd160 "mo:bitcoin/Ripemd160"; +/// ``` + import Nat "mo:core/Nat"; import Nat64 "mo:core/Nat64"; import VarArray "mo:core/VarArray"; @@ -5,6 +15,14 @@ import VarArray "mo:core/VarArray"; import Common "Common"; module { + /// Computes the RIPEMD-160 digest of `array`. + /// + /// Example: + /// ```motoko include=import + /// let digest = Ripemd160.hash([0x01, 0x02, 0x03]); + /// ``` + /// + /// Never traps. Always returns exactly 20 bytes. // Hash the given array and return finalized result. public func hash(array : [Nat8]) : [Nat8] { let digest = Digest(); @@ -12,6 +30,15 @@ module { digest.sum(); }; + /// Incremental RIPEMD-160 digest state. + /// + /// Example: + /// ```motoko include=import + /// let d = Ripemd160.Digest(); + /// d.write([0x01, 0x02]); + /// d.write([0x03]); + /// let digest = d.sum(); + /// ``` public class Digest() { private let s : [var Nat32] = VarArray.repeat(0, 5); private let buf : [var Nat8] = VarArray.repeat(0, 64); @@ -27,6 +54,9 @@ module { initialize(); + /// Resets the digest to its initial state. + /// + /// Never traps. public func reset() { bytes := 0; initialize(); @@ -712,6 +742,9 @@ module { s[4] := t +% b1 +% c2; }; + /// Adds `data` to the digest state. + /// + /// Never traps. Accepts an array of any length, including the empty array. public func write(data : [Nat8]) { var bufsize : Nat = (bytes % 64).toNat(); var transformOffset : Nat = 0; @@ -747,6 +780,11 @@ module { }; }; + /// Finalizes and returns the current digest as 20 bytes. + /// + /// Never traps. The digest state remains usable after `sum`, but further + /// `write` calls will continue from the padded state; call `reset` first + /// to start a fresh hash. public func sum() : [Nat8] { let pad : [var Nat8] = VarArray.repeat( 0, diff --git a/src/Segwit.mo b/src/Segwit.mo index 649f520..eb6ce5e 100644 --- a/src/Segwit.mo +++ b/src/Segwit.mo @@ -1,3 +1,13 @@ +/// SegWit address encoding and decoding. +/// +/// Implements conversion between witness programs and their Bech32/Bech32m +/// textual representation as defined in BIP173 and BIP350. +/// +/// Import from the bitcoin package to use this module. +/// ```motoko name=import +/// import Segwit "mo:bitcoin/Segwit"; +/// ``` + import Array "mo:core/Array"; import Nat "mo:core/Nat"; import Nat16 "mo:core/Nat16"; @@ -10,11 +20,38 @@ import Bech32 "Bech32"; module { + /// A SegWit witness program. + /// + /// `version` is the witness version in `0..16` (`0` for P2WPKH/P2WSH, + /// `1` for P2TR, etc.). + /// `program` is the witness program bytes. Its length must be 2..40 + /// bytes; for `version = 0` it must be exactly 20 (P2WPKH) or 32 bytes + /// (P2WSH). public type WitnessProgram = { version : Nat8; program : [Nat8]; }; + /// Encodes a witness program as a SegWit address. + /// + /// `hrp` is the human-readable prefix that identifies the network + /// (`"bc"` for mainnet, `"tb"` for testnet, `"bcrt"` for regtest). + /// Uses Bech32 for witness version 0 and Bech32m for witness version >= 1. + /// After encoding, the result is round-tripped through `decode` to verify + /// it conforms to BIP173/BIP350. + /// + /// Example: + /// ```motoko include=import + /// let result = Segwit.encode("bc", { version = 0; program = [0x00] }); + /// ``` + /// + /// Returns `#err(message)` when the bit-group conversion of `program` + /// fails (e.g. invalid padding) or when the round-trip `decode` rejects + /// the produced address (size or version constraints violated). + /// + /// Traps if `hrp` is empty, contains characters outside `'!'`..`'~'`, + /// contains uppercase letters, or if the resulting Bech32 string would + /// exceed 90 characters — these are inherited from `Bech32.encode`. // Convert a Witness Program to a SegWit Address. public func encode(hrp : Text, { version; program } : WitnessProgram) : Result { @@ -47,6 +84,29 @@ module { }; }; + /// Decodes a SegWit address into `(hrp, witnessProgram)`. + /// + /// Validates witness version, witness program length, and Bech32/Bech32m + /// compatibility with the witness version. + /// + /// Example: + /// ```motoko include=import + /// let decoded = Segwit.decode("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"); + /// ``` + /// + /// Never traps. Returns `#err(message)` with one of: + /// - any error from `Bech32.decode` (invalid character, mixed casing, + /// bad separator, bad checksum, invalid HRP), + /// - `"Invalid data length."` — the 5-bit-group payload is empty or + /// exceeds 65 groups, + /// - `"Invalid witness version."` — the first group is `> 16`, + /// - `"Wrong output size."` — the converted program is shorter than + /// 2 or longer than 40 bytes, + /// - `"Program size does not match witness version."` — a v0 program + /// that is not exactly 20 or 32 bytes, + /// - `"Encoding does not match witness version."` — v0 with Bech32m + /// or v1+ with Bech32, + /// - any error from the bit-group conversion (e.g. invalid padding). // Convert a segwit address into a numan-readable part (HRP) and a Witness Program. // Decodes using Bech32. public func decode(address : Text) : Result<(Text, WitnessProgram), Text> { diff --git a/src/bitcoin/Address.mo b/src/bitcoin/Address.mo index 16f6d94..96154dc 100644 --- a/src/bitcoin/Address.mo +++ b/src/bitcoin/Address.mo @@ -1,3 +1,12 @@ +/// Bitcoin address helpers. +/// +/// Provides parsing, script generation, and equality checks for supported +/// Bitcoin address variants. +/// +/// ```motoko name=import +/// import Address "mo:bitcoin/bitcoin/Address"; +/// ``` + import { type Result } "mo:core/Types"; import Segwit "../Segwit"; @@ -7,6 +16,19 @@ import Script "Script"; import Types "Types"; module { + /// Parses textual address data into a typed address variant. + /// + /// First tries SegWit (Bech32/Bech32m) decoding. If that fails, falls back + /// to P2PKH (Base58Check) decoding. + /// + /// Note: any successfully decoded SegWit address is returned as + /// `#p2tr_key(address)`, regardless of the actual witness version + /// (P2WPKH, P2WSH, and P2TR all collapse to the same variant). Inspect + /// the address text or call `Segwit.decode` directly if you need to + /// distinguish them. + /// + /// Never traps. Returns `#err("Failed to decode address ...")` when the + /// input is neither a valid SegWit nor a valid P2PKH address. public func addressFromText(address : Text) : Result { switch (Segwit.decode(address)) { case (#ok _) { @@ -25,7 +47,14 @@ module { #err("Failed to decode address " # address); }; - // Obtain scriptPubKey from given address. + /// Builds the locking script (`scriptPubKey`) for a given address. + /// + /// Never traps. Returns `#err(...)` when: + /// - `address` is `#p2tr_script`, which is not yet supported (returns + /// `#err("Calling scriptPubKey on an unknown address type")`). + /// - The underlying `P2pkh.makeScript` or + /// `P2tr.makeScriptFromP2trKeyAddress` call fails (e.g. malformed + /// address text). public func scriptPubKey( address : Types.Address ) : Result { @@ -42,7 +71,12 @@ module { }; }; - // Check if the given addresses are equal. + /// Compares two addresses for value equality. + /// + /// Two addresses are considered equal only when they have the same variant + /// and the same underlying text. Cross-variant comparisons (e.g. P2PKH + /// against P2TR) always return `false`, even if the keys are related. + /// Never traps. public func isEqual( address1 : Types.Address, address2 : Types.Address, diff --git a/src/bitcoin/Bitcoin.mo b/src/bitcoin/Bitcoin.mo index a92fbfa..a580b61 100644 --- a/src/bitcoin/Bitcoin.mo +++ b/src/bitcoin/Bitcoin.mo @@ -1,3 +1,12 @@ +/// High-level Bitcoin transaction construction and signing helpers. +/// +/// This module focuses on P2PKH transaction flows using provided UTXOs, +/// destination outputs, and an abstract ECDSA signing proxy. +/// +/// ```motoko name=import +/// import Bitcoin "mo:bitcoin/bitcoin/Bitcoin"; +/// ``` + import Array "mo:core/Array"; import Blob "mo:core/Blob"; import List "mo:core/List"; @@ -24,14 +33,36 @@ module { let dustThreshold : Satoshi = 10_000; let defaultSequence : Nat32 = 0xffffffff; - type EcdsaProxy = { - // Takes a message hash and a derivation path, outputs a signature encoded - // as the concatenation of big endian representation of r and s values. + /// Interface for an external ECDSA signing service (typically the + /// Internet Computer's threshold-ECDSA management canister). + /// + /// `sign(messageHash, derivationPath)` takes a 32-byte message hash and + /// a BIP32 derivation path (each path component encoded as a `Blob`) + /// and must return a 64-byte signature: the big-endian `r` (32 bytes) + /// concatenated with the big-endian `s` (32 bytes). + /// + /// `publicKey()` must return `(sec1Pubkey, chainCode)` where `sec1Pubkey` + /// is the SEC1-encoded public key (33 bytes compressed or 65 bytes + /// uncompressed) corresponding to `derivationPath = []` (i.e. the root + /// public key), and `chainCode` is the 32-byte chain code. + public type EcdsaProxy = { sign : (Blob, [Blob]) -> Blob; - // Outputs SEC-1 encoded public key and a chain code. publicKey : () -> (Blob, Blob); }; + /// Builds an unsigned transaction from UTXOs and destination outputs. + /// + /// Selects UTXOs in the order given by `utxos` until the sum covers + /// `fees + sum(destinations)`. Adds a change output to `changeAddress` + /// when the leftover exceeds the dust threshold (10_000 satoshis). + /// + /// Never traps. Returns `#err(message)` when: + /// - `version` is not `1` or `2` + /// (`"Unexpected version number: ..."`), + /// - any destination or `changeAddress` cannot be converted to a + /// `scriptPubKey` (errors propagated from `Address.scriptPubKey`), + /// - the supplied `utxos` cannot cover `fees + sum(destinations)` + /// (`"Insufficient balance"`). // Builds a transaction. // `version` is the transaction version. Currently only 1 and 2 are // supported. @@ -114,6 +145,20 @@ module { ); }; + /// Signs all inputs of a P2PKH transaction. + /// + /// Computes the SIGHASH_ALL signature hash for each input, asks + /// `ecdsaProxy` for a signature, DER-encodes it, appends the sighash type + /// byte, and stores the resulting ` ` scriptSig on each input. + /// Mutates `transaction.txInputs[i].script` in place. + /// + /// Returns `#err(message)` when `Address.scriptPubKey(sourceAddress)` + /// fails. Otherwise returns `#ok(transaction)`. + /// + /// Traps if `transaction.txInputs` is non-empty and the supplied + /// `ecdsaProxy.sign` returns a signature blob that cannot be DER-encoded + /// by `Der.encodeSignature` (in practice this requires a signature blob + /// other than the expected 64-byte concatenation of `r` and `s`). // Sign given transaction. // `sourceAddress` is the spender's address appearing in the TxOutputs being // spent from. @@ -176,7 +221,12 @@ module { }; }; - // Create and sign a transaction. + /// Builds and signs a P2PKH transaction in one step. + /// + /// Equivalent to calling `buildTransaction` followed by + /// `signP2pkhTransaction`. Returns `#err(message)` propagated from either + /// step (see those functions for the full list of error and trap + /// conditions). // `sourceAddress` is the spender's address appearing in the TxOutputs being // spent from. // `ecdsaProxy` is an interface for ECDSA signing functionality. diff --git a/src/bitcoin/P2pkh.mo b/src/bitcoin/P2pkh.mo index eac4aaf..a155cc1 100644 --- a/src/bitcoin/P2pkh.mo +++ b/src/bitcoin/P2pkh.mo @@ -1,3 +1,9 @@ +/// P2PKH address and script helpers. +/// +/// ```motoko name=import +/// import P2pkh "mo:bitcoin/bitcoin/P2pkh"; +/// ``` + import Array "mo:core/Array"; import { type Result; type Iter } "mo:core/Types"; @@ -13,13 +19,27 @@ module { type PublicKey = Ecdsa.PublicKey; type Script = Script.Script; + /// P2PKH address string type alias. + /// + /// A Base58Check string. Mainnet addresses start with `1`; + /// testnet/regtest addresses start with `m` or `n`. public type Address = Types.P2PkhAddress; + /// Decoded P2PKH components. + /// + /// `publicKeyHash` is the 20-byte HASH160 of the SEC1-encoded + /// public key. public type DecodedAddress = { network : Types.Network; publicKeyHash : [Nat8]; }; - // Create P2PKH script for the given P2PKH address. + /// Creates a standard P2PKH locking script from an address. + /// + /// Returns `#err(message)` propagated from `decodeAddress` (see that + /// function for the exact error categories). + /// + /// Traps if Base58 decoding of `address` would underflow because the + /// payload is shorter than 4 bytes (inherited from `Base58Check.decode`). public func makeScript(address : Address) : Result { switch (decodeAddress(address)) { case (#ok { network = _; publicKeyHash }) { @@ -49,7 +69,11 @@ module { }; }; - // Derive P2PKH address from given public key. + /// Derives a Base58Check P2PKH address from a SEC1 public key. + /// + /// Always returns a valid address (Base58Check string). Never traps for + /// any `sec1PublicKey` input; the public-key bytes are hashed verbatim and + /// no length validation is performed. public func deriveAddress( network : Types.Network, sec1PublicKey : EcdsaTypes.Sec1PublicKey, @@ -70,7 +94,18 @@ module { Base58Check.encode(versionedHash); }; - // Decode P2PKH hash into its network and public key hash components. + /// Decodes a P2PKH address into network and HASH160 payload. + /// + /// Returns `#err(message)` when: + /// - `address` is not valid Base58Check (alphabet error or checksum + /// mismatch) — `"Could not base58 decode address."`, + /// - the version byte is not `0x00` (mainnet) or `0x6f` (testnet/regtest) + /// — `"Unrecognized network id."`, + /// - the decoded payload does not contain a version byte followed by + /// exactly 20 hash bytes — `"Could not decode address."`. + /// + /// Traps if the Base58 payload is shorter than 4 bytes (inherited from + /// `Base58Check.decode`). public func decodeAddress(address : Address) : Result { let decoded : Iter = switch (Base58Check.decode(address)) { diff --git a/src/bitcoin/P2tr.mo b/src/bitcoin/P2tr.mo index a869976..94f203a 100644 --- a/src/bitcoin/P2tr.mo +++ b/src/bitcoin/P2tr.mo @@ -1,3 +1,9 @@ +/// Taproot (P2TR) address and tweak helpers. +/// +/// ```motoko name=import +/// import P2tr "mo:bitcoin/bitcoin/P2tr"; +/// ``` + import Array "mo:core/Array"; import Nat "mo:core/Nat"; import { type Result } "mo:core/Types"; @@ -18,7 +24,9 @@ module { }; type Script = Script.Script; + /// P2TR key-path address string alias. public type P2trKeyAddress = Types.P2trKeyAddress; + /// Decoded P2TR address payload. public type DecodedAddress = { network : Types.Network; publicKeyHash : [Nat8]; @@ -27,6 +35,9 @@ module { /// Create script for the given P2TR key spend address (see /// [BIP341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki) /// for more details). + /// + /// Never traps. Returns `#err(message)` propagated from `Segwit.decode` + /// when `address` is not a valid SegWit string. public func makeScriptFromP2trKeyAddress(address : P2trKeyAddress) : Result { switch (Segwit.decode(address)) { case (#ok(_, { version = _; program })) { @@ -43,7 +54,11 @@ module { }; }; - // Create script for the given P2TR key spend address. + /// Creates a tapscript leaf script from a BIP340 public key. + /// + /// Never traps. Returns + /// `#err("Invalid BIP-340 public key length: expected 32 but got N")` + /// when `bip340_spender_public_key.size() != 32`. public func leafScript(bip340_spender_public_key : [Nat8]) : Result { if (bip340_spender_public_key.size() != 32) { return #err("Invalid BIP-340 public key length: expected 32 but got " # bip340_spender_public_key.size().toText()); @@ -56,6 +71,11 @@ module { ]); }; + /// Computes the TapLeaf hash for a leaf script. + /// + /// Traps if any `#data` element of `leaf_script` is larger than `2^32 - 1` + /// bytes (inherited from `Script.toBytes`). For scripts produced by this + /// module that limit is never reached in practice. public func leafHash(leaf_script : Script.Script) : [Nat8] { // BIP-342 tapscript let TAPROOT_LEAF_TAPSCRIPT : [Nat8] = [0xc0]; @@ -71,6 +91,12 @@ module { /// ``` /// in `taproot_tweak_pubkey` function in /// [BIP341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki). + /// + /// Never traps. Returns `#err(message)` when: + /// - `internal_key.size() != 32`, + /// - `hash.size() != 32`, or + /// - the tagged hash interpreted as a big-endian integer is `≥` the + /// secp256k1 field prime (probability under `2^-128`). public func tweakFromKeyAndHash(internal_key : [Nat8], hash : [Nat8]) : Result { if (internal_key.size() != 32) { return #err("Failed to compute tweak, invalid internal key length: expected 32 but got " # internal_key.size().toText()); @@ -99,6 +125,12 @@ module { /// ``` /// `taproot_tweak_pubkey` function in /// [BIP341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki). + /// + /// Never traps. Returns `#err("Failed to tweak public key, invalid public key")` + /// when `public_key_bip340_bytes` does not encode a valid x-only point on + /// secp256k1, or the lifted point is the point at infinity. Returns + /// `#err("Tweaking produced an invalid public key")` when the tweaked + /// point is at infinity (probability under `2^-128`). public func tweakPublicKey(public_key_bip340_bytes : [Nat8], tweak : Fp.Fp) : Result { let even_point_flag : [Nat8] = [0x02]; let public_key_sec1_bytes = [even_point_flag, public_key_bip340_bytes].flatten(); diff --git a/src/bitcoin/Script.mo b/src/bitcoin/Script.mo index e92a5ea..da4de8f 100644 --- a/src/bitcoin/Script.mo +++ b/src/bitcoin/Script.mo @@ -1,3 +1,9 @@ +/// Bitcoin Script instruction and serialization utilities. +/// +/// ```motoko name=import +/// import Script "mo:bitcoin/bitcoin/Script"; +/// ``` + import Array "mo:core/Array"; import List "mo:core/List"; import Nat16 "mo:core/Nat16"; @@ -16,6 +22,7 @@ module Script { let maxNat32 = 0xffffffff; // Full set of opcodes from Bitcoin Core 23.0. // Not all opcodes are supported: see encodeOpcode and decodeOpcode. + /// Script opcode variants. public type Opcode = { #OP_0; #OP_FALSE; @@ -157,12 +164,19 @@ module Script { }; // An instruction is either an opcode or data. + /// A single script instruction: either an opcode or a data push. + /// + /// `#opcode(op)` represents one of the script opcodes. + /// `#data(bytes)` represents a push of `bytes` onto the stack; the + /// appropriate `OP_PUSHBYTES_N` / `OP_PUSHDATA{1,2,4}` prefix is + /// emitted automatically when serializing. public type Instruction = { #opcode : Opcode; #data : [Nat8]; }; // A script is an array of instructions. + /// Script program as a sequence of instructions. public type Script = [Instruction]; // Convert given opcode to its byte representation. @@ -489,6 +503,22 @@ module Script { // script size from data. Reading size is required when deserializing scripts // that were serialized as part of transactions. If readSize is false, will // read all bytes in data. + /// Parses a serialized script from `data`. + /// + /// When `readSize` is true, this function first reads a varint-prefixed + /// script length and stops after that many bytes. When `readSize` is false, + /// the iterator is consumed to exhaustion. + /// + /// Never traps. Returns `#err(message)` with one of: + /// - `"Could not read size."` — `readSize` is `true` but the leading + /// varint cannot be read. + /// - `"Could not decode opcode: N"` — the byte `N` is not in the + /// supported subset of opcodes recognised by `decodeOpcode`. + /// - `"Error docoding instruction."` — the iterator was exhausted while + /// reading the data payload of an `OP_PUSHDATA*` or short-push + /// instruction (note the typo, preserved for compatibility). + /// - `"Truncated script."` — `readSize` is `true` and the iterator was + /// exhausted before the declared script length had been consumed. public func fromBytes(data : Iter, readSize : Bool) : Result { let size = if (readSize) { switch (ByteUtils.readVarint(data)) { @@ -581,6 +611,10 @@ module Script { }; // Serialize given script to bytes. + /// Serializes a script to bytes using Bitcoin script encoding rules. + /// + /// Traps with `"Data too long to encode."` if any `#data` element is + /// larger than `2^32 - 1` bytes (the maximum supported by `OP_PUSHDATA4`). public func toBytes(script : Script) : [Nat8] { let buf = List.empty(); let opPushData1 : Nat = encodeOpcode(#OP_PUSHDATA1).toNat(); diff --git a/src/bitcoin/Transaction.mo b/src/bitcoin/Transaction.mo index 0b7fb9f..a35179e 100644 --- a/src/bitcoin/Transaction.mo +++ b/src/bitcoin/Transaction.mo @@ -1,3 +1,9 @@ +/// Bitcoin transaction parsing, serialization, and signature hash helpers. +/// +/// ```motoko name=import +/// import Transaction "mo:bitcoin/bitcoin/Transaction"; +/// ``` + import Array "mo:core/Array"; import Blob "mo:core/Blob"; import List "mo:core/List"; @@ -19,9 +25,17 @@ import Types "Types"; import Witness "Witness"; module { - // Deserialize transaction from data with the following layout: - // | version | maybe witness flags | len(txIns) | txIns | len(txOuts) | txOuts - // | locktime | witness if witness flags present | + /// Deserializes a transaction from raw bytes. + /// + /// Supports both legacy and witness transaction layouts. + /// + /// Never traps. Returns `#err(message)` when the byte stream is + /// malformed (e.g. truncated header, invalid varint sizes, malformed + /// inputs, outputs, witnesses or scripts). Specific messages include + /// `"Could not read version."`, `"Could not read txInCount."`, + /// `"Could not read txOutCount."`, `"Could not read locktime."`, + /// and errors propagated from `TxInput.fromBytes`, `TxOutput.fromBytes`, + /// `Witness.fromBytes`, and `Script.fromBytes`. public func fromBytes(data : Iter) : Result { var has_witness = false; @@ -142,7 +156,17 @@ module { ); }; - // Representation of a Bitcoin transaction. + /// Mutable Bitcoin transaction representation. + /// + /// Constructor arguments: + /// - `version` — transaction format version (typically `1` or `2`). + /// - `_txIns` — transaction inputs. The `script` field of each input + /// is mutable and is overwritten by the signing helpers. + /// - `_txOuts` — transaction outputs. + /// - `_witnesses` — per-input witness stacks, with one entry per input + /// (use `Witness.EMPTY_WITNESS` for inputs without a witness). + /// - `locktime` — block height or timestamp before which the + /// transaction is invalid (`0` to disable). public class Transaction( version : Nat32, _txIns : [TxInput.TxInput], @@ -151,8 +175,11 @@ module { locktime : Nat32, ) { + /// Transaction inputs. public let txInputs : [TxInput.TxInput] = _txIns; + /// Transaction outputs. public let txOutputs : [TxOutput.TxOutput] = _txOuts; + /// Per-input witness stacks. public let witnesses : [var Witness.Witness] = _witnesses; /// Compute the transaction id by double hashing @@ -163,6 +190,10 @@ module { /// As per /// [BIP141](https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki), /// the id that includes witness is denoted as wtxid. + /// + /// Traps if any contained `Script` has a `#data` element larger than + /// `2^32 - 1` bytes (inherited from `Script.toBytes`). Otherwise never + /// traps. public func txid() : [Nat8] { let doubleHash : [Nat8] = Hash.doubleSHA256(toBytesIgnoringWitness()); Array.tabulate( @@ -173,6 +204,20 @@ module { ); }; + /// Creates the legacy P2PKH signature hash for one input. + /// + /// Currently supports only `SIGHASH_ALL` semantics. Mutates the + /// `script` field of every `txInputs[i]` (clears all of them, then + /// installs `scriptPubKey` on the input being signed). Callers should + /// not rely on input scripts being preserved across this call. + /// + /// Traps when: + /// - `sigHashType & 0x1f == SIGHASH_SINGLE` (failed assert), + /// - `sigHashType & 0x1f == SIGHASH_NONE` (failed assert), + /// - `sigHashType & SIGHASH_ANYONECANPAY != 0` (failed assert), + /// - `txInputIndex.toNat() >= txInputs.size()` (out-of-bounds index), + /// - any contained `Script` has a `#data` element larger than + /// `2^32 - 1` bytes (inherited from `Script.toBytes`). // Create a signature hash for the given TxIn index. // Only SIGHASH_ALL is currently supported. // Output: Signature Hash. @@ -214,6 +259,13 @@ module { /// address and the `txInputIndex` of the input being signed. The full signature /// hash computation algorithm is described in /// [BIP341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#user-content-Signature_validation_rules). + /// + /// Traps when: + /// - `txInputIndex.toNat() >= txInputs.size()` (out-of-bounds index), + /// - `amounts.size() != txInputs.size()` (out-of-bounds index inside + /// the SHA-amounts loop), + /// - any contained `Script` has a `#data` element larger than + /// `2^32 - 1` bytes (inherited from `Script.toBytes`). public func createTaprootKeySpendSignatureHash( amounts : [Nat64], scriptPubKey : Script.Script, @@ -233,6 +285,11 @@ module { /// [BIP342](https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki). /// /// This method traps if the `leaf_hash` is not 32 bytes long. + /// Also traps when: + /// - `txInputIndex.toNat() >= txInputs.size()` (out-of-bounds index), + /// - `amounts.size() != txInputs.size()`, + /// - any contained `Script` has a `#data` element larger than + /// `2^32 - 1` bytes (inherited from `Script.toBytes`). public func createTaprootScriptSpendSignatureHash( amounts : [Nat64], scriptPubKey : Script.Script, @@ -346,6 +403,10 @@ module { /// Serialize transaction to bytes with layout: /// `| version | witness flags if it is present | len(txIns) | txIns | len(txOuts) | txOuts | witnesses | locktime |` + /// Serializes the transaction including witness (if present). + /// + /// Traps if any contained `Script` has a `#data` element larger than + /// `2^32 - 1` bytes (inherited from `Script.toBytes`). public func toBytes() : [Nat8] { let has_non_empty_witness = witnesses.toArray().foldLeft( false, @@ -502,6 +563,10 @@ module { /// ignoring the witness. See /// [BIP141](https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki) /// for more details. + /// Serializes the transaction excluding witness data. + /// + /// Traps if any contained `Script` has a `#data` element larger than + /// `2^32 - 1` bytes (inherited from `Script.toBytes`). public func toBytesIgnoringWitness() : [Nat8] { // Serialize TxInputs to bytes. let serializedTxIns : [[Nat8]] = txInputs.map( diff --git a/src/bitcoin/TxInput.mo b/src/bitcoin/TxInput.mo index 6f8ca1c..7e43e3c 100644 --- a/src/bitcoin/TxInput.mo +++ b/src/bitcoin/TxInput.mo @@ -1,3 +1,9 @@ +/// Bitcoin transaction input type and codec utilities. +/// +/// ```motoko name=import +/// import TxInput "mo:bitcoin/bitcoin/TxInput"; +/// ``` + import Blob "mo:core/Blob"; import { type Iter; type Result } "mo:core/Types"; import VarArray "mo:core/VarArray"; @@ -8,6 +14,15 @@ import Script "Script"; import Types "Types"; module { + /// Deserializes a transaction input from raw bytes. + /// + /// Never traps. Returns `#err(message)` when the byte stream is too short + /// or the embedded script is malformed: + /// - `"Could not read prevTxId."`, + /// - `"Could not read prevTxOutputIndex."`, + /// - `"Could not deserialize scriptSig: ..."` (propagated from + /// `Script.fromBytes`), + /// - `"Could not read sequence."`. // Deserialize a TxInput from bytes with layout: // | prevTxId | prevTx output index | script | sequence | public func fromBytes(data : Iter) : Result { @@ -40,16 +55,32 @@ module { return #ok txIn; }; + /// Bitcoin transaction input. + /// + /// Constructor arguments: + /// - `_prevOutput` — the previous transaction output being spent. + /// - `_sequence` — the input sequence number. Use `0xffffffff` to + /// disable RBF/locktime semantics. + /// + /// `script` (the unlocking `scriptSig`) is initialized to the empty + /// script and is mutated in place by the signing helpers. // Representation of a TxInput of a Bitcoin transaction. A TxInput is linked // to a previous transaction output given by prevOutput. public class TxInput(_prevOutput : Types.OutPoint, _sequence : Nat32) { + /// Referenced previous output. public let prevOutput : Types.OutPoint = _prevOutput; + /// Input sequence value. public let sequence : Nat32 = _sequence; // Unlocking script. This is mutuable to enable signature hash construction // for a transaction without having to clone the transaction. + /// Unlocking script (`scriptSig`) for this input. public var script : Script.Script = []; + /// Serializes this input using Bitcoin wire format. + /// + /// Traps if `script` has a `#data` element larger than `2^32 - 1` bytes + /// (inherited from `Script.toBytes`). // Serialize to bytes with layout: // | prevTxId | prevTx output index | script | sequence |. public func toBytes() : [Nat8] { diff --git a/src/bitcoin/TxOutput.mo b/src/bitcoin/TxOutput.mo index cca1b7b..3296ead 100644 --- a/src/bitcoin/TxOutput.mo +++ b/src/bitcoin/TxOutput.mo @@ -1,3 +1,9 @@ +/// Bitcoin transaction output type and codec utilities. +/// +/// ```motoko name=import +/// import TxOutput "mo:bitcoin/bitcoin/TxOutput"; +/// ``` + import { type Iter; type Result } "mo:core/Types"; import VarArray "mo:core/VarArray"; @@ -7,6 +13,12 @@ import Script "Script"; import Types "Types"; module { + /// Deserializes a transaction output from raw bytes. + /// + /// Never traps. Returns `#err(message)` when the byte stream is too short + /// or the script is malformed: + /// - `"Could not read TxOut amount"`, + /// - `"Could not decode script: ..."` (propagated from `Script.fromBytes`). // Deserialize TxOutput from data with layout: // | amount | serialized script | public func fromBytes(data : Iter) : Result { @@ -23,13 +35,20 @@ module { }; }; + /// Bitcoin transaction output. // Representation of a TxOutput of a Bitcoin transaction. A TxOutput locks // specified amount of Satoshi with the given script. public class TxOutput(_amount : Types.Satoshi, _scriptPubKey : Script.Script) { + /// Output amount in satoshis. public let amount : Types.Satoshi = _amount; + /// Locking script (`scriptPubKey`). public let scriptPubKey : Script.Script = _scriptPubKey; + /// Serializes this output using Bitcoin wire format. + /// + /// Traps if `scriptPubKey` has a `#data` element larger than `2^32 - 1` + /// bytes (inherited from `Script.toBytes`). // Serialize to bytes with layout: | amount | serialized script | public func toBytes() : [Nat8] { let encodedScript = Script.toBytes(scriptPubKey); diff --git a/src/bitcoin/Types.mo b/src/bitcoin/Types.mo index 90d0c3a..5087fed 100644 --- a/src/bitcoin/Types.mo +++ b/src/bitcoin/Types.mo @@ -1,8 +1,16 @@ +/// Shared Bitcoin types and constants. +/// +/// ```motoko name=import +/// import Types "mo:bitcoin/bitcoin/Types"; +/// ``` + module { // A single unit of Bitcoin. + /// Bitcoin amount denominated in satoshis (`1 BTC = 100_000_000`). public type Satoshi = Nat64; // The type of Bitcoin network. + /// Supported Bitcoin networks. public type Network = { #Mainnet; #Regtest; @@ -10,34 +18,70 @@ module { }; // A reference to a transaction output. + /// Outpoint identifying a previous transaction output. + /// + /// `txid` is the 32-byte transaction hash in **serialization byte order** + /// (the internal little-endian-ish order used inside transactions and + /// block headers). This is the **reverse** of the byte order used in + /// block explorers and JSON-RPC output — reverse the bytes before + /// displaying or comparing against a user-supplied txid string. + /// `vout` is the zero-based output index within that transaction. public type OutPoint = { txid : Blob; vout : Nat32; }; // An unspent transaction output. + /// Unspent transaction output (UTXO) data. + /// + /// `outpoint` references the funding transaction's output. + /// `value` is the amount locked in the output, in satoshis. + /// `height` is the block height at which the funding transaction was + /// confirmed (`0` for unconfirmed UTXOs supplied by the caller). public type Utxo = { outpoint : OutPoint; value : Satoshi; height : Nat32; }; + /// Signature hash type bitfield. + /// + /// Combine the base mode (`SIGHASH_ALL`, `SIGHASH_NONE`, + /// `SIGHASH_SINGLE`) with the optional `SIGHASH_ANYONECANPAY` flag + /// using `or`. Encoded as the trailing byte appended to a DER signature. public type SighashType = Nat32; + /// Sign all inputs and all outputs (the default). public let SIGHASH_ALL : SighashType = 0x01; + /// Sign all inputs and no outputs. public let SIGHASH_NONE : SighashType = 0x02; + /// Sign all inputs and only the output at the same index as the input. public let SIGHASH_SINGLE : SighashType = 0x03; + /// OR-combine with one of the above to sign only the input being signed, + /// allowing other inputs to be added or removed without invalidating it. public let SIGHASH_ANYONECANPAY : SighashType = 0x80; + /// Decoded Bitcoin private key metadata. + /// + /// `network` is the network the WIF/key is for. + /// `key` is the raw 256-bit secret scalar interpreted as a `Nat` + /// (must be in `[1, secp256k1_order)`). + /// `compressedPublicKey` indicates whether the corresponding public key + /// should be encoded in SEC1 compressed form (33 bytes) rather than + /// uncompressed (65 bytes). public type BitcoinPrivateKey = { network : Network; key : Nat; compressedPublicKey : Bool; }; + /// Legacy Base58 P2PKH address string. public type P2PkhAddress = Text; + /// SegWit v1 key-path (P2TR) address string. public type P2trKeyAddress = Text; + /// SegWit v1 script-path (P2TR) address string. public type P2trScriptAddress = Text; + /// Supported Bitcoin address variants. public type Address = { #p2pkh : P2PkhAddress; #p2tr_key : P2trKeyAddress; diff --git a/src/bitcoin/Wif.mo b/src/bitcoin/Wif.mo index 105d5be..af049ef 100644 --- a/src/bitcoin/Wif.mo +++ b/src/bitcoin/Wif.mo @@ -1,3 +1,9 @@ +/// Wallet Import Format (WIF) decoding utilities. +/// +/// ```motoko name=import +/// import Wif "mo:bitcoin/bitcoin/Wif"; +/// ``` + import { type Iter; type Result } "mo:core/Types"; import Base58Check "../Base58Check"; @@ -6,6 +12,11 @@ import Common "../Common"; import Types "Types"; module { + /// Textual WIF private key representation. + /// + /// A Base58Check string. Mainnet uncompressed keys start with `5`, + /// compressed mainnet keys start with `K` or `L`; testnet/regtest keys + /// start with `9` (uncompressed) or `c` (compressed). public type WifPrivateKey = Text; // Map network to WIF version prefix. @@ -35,6 +46,20 @@ module { }; }; + /// Decodes a WIF key into network, scalar value, and compression flag. + /// + /// Returns `#err(message)` when: + /// - `key` is not valid Base58Check (alphabet error or checksum mismatch) + /// — `"Could not base58 decode key."`, + /// - the trailing byte is present but not `0x01` — + /// `"Invalid compression flag."`, + /// - the payload is not exactly `version || 32-byte key [|| 0x01]` — + /// `"Invalid key format."`, + /// - the version byte is not `0x80` (mainnet) or `0xef` (testnet/regtest) + /// — `"Unknown network version."`. + /// + /// Traps if the Base58 payload is shorter than 4 bytes (inherited from + /// `Base58Check.decode`). // Decode WIF private key to extract network, private key, // and compression flag. public func decode(key : WifPrivateKey) : Result { diff --git a/src/bitcoin/Witness.mo b/src/bitcoin/Witness.mo index 3b23353..23ad27b 100644 --- a/src/bitcoin/Witness.mo +++ b/src/bitcoin/Witness.mo @@ -1,3 +1,9 @@ +/// Bitcoin witness stack serialization utilities. +/// +/// ```motoko name=import +/// import Witness "mo:bitcoin/bitcoin/Witness"; +/// ``` + import Array "mo:core/Array"; import List "mo:core/List"; import Nat "mo:core/Nat"; @@ -8,14 +14,20 @@ import ByteUtils "../ByteUtils"; module { // Witness consists of a sequence of byte arrays. + /// Witness stack type for a single transaction input. public type Witness = [[Nat8]]; + /// Empty witness stack constant. public let EMPTY_WITNESS : Witness = []; /// A witness is serialized as /// `| num_elements | e_1_size | e_1 | ... | e_n_size | e_n |`. See /// [BIP144](https://github.com/bitcoin/bips/blob/master/bip-0144.mediawiki) /// for more details. + /// + /// Traps if `witness.size() >= 2^64` or any element's size is `>= 2^64` + /// (inherited from `ByteUtils.writeVarint`). In practice neither limit is + /// reachable on the IC. public func toBytes(witness : Witness) : [Nat8] { let numElements = witness.size(); let buffer = List.empty<[Nat8]>(); @@ -29,6 +41,13 @@ module { }; + /// Deserializes a witness stack from raw bytes. + /// + /// Never traps. Returns `#err(message)` when the byte stream is + /// malformed: + /// - `"Could not read number of elements in the witness"`, + /// - `"Could not read witness element size"`, + /// - `"Could not read witness element"`. public func fromBytes(data : Iter) : Result { let numElements = switch (ByteUtils.readVarint(data)) { case (?numElements) { numElements }; diff --git a/src/ec/Affine.mo b/src/ec/Affine.mo index 973dc7d..509bb84 100644 --- a/src/ec/Affine.mo +++ b/src/ec/Affine.mo @@ -1,3 +1,9 @@ +/// Affine elliptic curve point utilities. +/// +/// ```motoko name=import +/// import Affine "mo:bitcoin/ec/Affine"; +/// ``` + import VarArray "mo:core/VarArray"; import Common "../Common"; @@ -6,12 +12,15 @@ import FpBase "Fp"; module { type Fp = FpBase.Fp; + /// Affine point representation. public type Point = { #infinity : Curves.Curve; #point : (Fp, Fp, Curves.Curve); }; - // Check if the given point is valid. + /// Checks whether a point satisfies the curve equation. + /// + /// Always returns `true` for `#infinity`. Never traps. public func isOnCurve(point : Point) : Bool { switch point { case (#infinity(_)) { @@ -27,7 +36,10 @@ module { }; }; - // Check if the two given affine points are equal. + /// Compares two affine points for equality. + /// + /// Two `#infinity` values are equal iff they reference the same curve. + /// Cross-variant comparisons return `false`. Never traps. public func isEqual(point1 : Point, point2 : Point) : Bool { switch (point1, point2) { case (#infinity(curve1), #infinity(curve2)) { @@ -42,6 +54,14 @@ module { }; }; + /// Decodes SEC1 compressed or uncompressed bytes into an affine point. + /// + /// Never traps. Returns `null` when: + /// - `data.size() < 33`, + /// - the leading byte is not `0x02`, `0x03`, or `0x04`, + /// - the size does not match the format (33 bytes for compressed, + /// 65 bytes for uncompressed), + /// - the resulting point is not on `curve`. // Deserialize given data into a point on the given curve. This supports // compressed and uncompressed SEC-1 formats. // Returns null if data is not in correct format, data size is not exactly @@ -99,7 +119,15 @@ module { }; }; - // Serialize given point to bytes in SEC-1 format. + /// Encodes an affine point into SEC1 bytes. + /// + /// `compressed = true` returns 33 bytes (`0x02`/`0x03` prefix indicating + /// the y parity, followed by the 32-byte x coordinate). + /// `compressed = false` returns 65 bytes (`0x04` prefix followed by the + /// 32-byte x and 32-byte y coordinates). + /// + /// Returns an empty array (`[]`) for `#infinity` (the point at infinity + /// has no SEC1 encoding). Never traps. public func toBytes(point : Point, compressed : Bool) : [Nat8] { switch point { case (#infinity(_)) { diff --git a/src/ec/Curves.mo b/src/ec/Curves.mo index 0e4f749..cf1986e 100644 --- a/src/ec/Curves.mo +++ b/src/ec/Curves.mo @@ -1,6 +1,17 @@ +/// Elliptic curve parameter definitions. +/// +/// ```motoko name=import +/// import Curves "mo:bitcoin/ec/Curves"; +/// ``` + import Fp "Fp"; module { + /// Prime-field short Weierstrass curve parameters. + /// + /// Defines a curve `y^2 = x^3 + a*x + b` over `F_p` with subgroup + /// order `r` and generator `(gx, gy)`. `Fp` is a convenience constructor + /// for field elements modulo `p`. public type Curve = { p : Nat; // Order (number of points on the curve) @@ -14,6 +25,7 @@ module { Fp : (Nat) -> Fp.Fp; }; + /// secp256k1 curve definition. public let secp256k1 : Curve = { p = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f; r = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141; @@ -26,6 +38,10 @@ module { }; }; + /// Compares two curves by core domain parameters. + /// + /// Compares `p`, `a`, `b`, `gx`, and `gy`. Does not compare the cofactor + /// or the embedded `Fp` constructor function. Never traps. public func isEqual(curve1 : Curve, curve2 : Curve) : Bool { curve1.p == curve2.p and curve1.a == curve2.a and curve1.b == curve2.b and curve1.gx == curve2.gx and curve1.gy == curve2.gy }; diff --git a/src/ec/Field.mo b/src/ec/Field.mo index acdb859..614a671 100644 --- a/src/ec/Field.mo +++ b/src/ec/Field.mo @@ -1,3 +1,9 @@ +/// Modular arithmetic primitives over natural numbers. +/// +/// ```motoko name=import +/// import Field "mo:bitcoin/ec/Field"; +/// ``` + import Int "mo:core/Int"; import Nat "mo:core/Nat"; @@ -5,6 +11,10 @@ import Numbers "Numbers"; module { // Compute a ** -1 mod n. + /// Computes modular inverse of `a` modulo `n`. + /// + /// Never traps. Returns `null` when `gcd(a, n) != 1` (in particular when + /// `a == 0` or when `n` is composite and `a` shares a factor with `n`). public func inverse(a : Nat, n : Nat) : ?Nat { let (gcd, x, _) = Numbers.eea(a, n); @@ -17,6 +27,10 @@ module { }; // Compute a**b mod n. + /// Computes `a^b mod n`. + /// + /// Returns `1` when `b == 0`, even if `n == 1`. Traps when `n == 0` + /// (division by zero in the underlying `mul`). public func pow(a : Nat, b : Nat, n : Nat) : Nat { if (b == 0) { return 1; @@ -36,6 +50,9 @@ module { }; // Compute a + b mod n. + /// Computes `(a + b) mod n`. + /// + /// Assumes `a < n` and `b < n`; never traps under that precondition. public func add(a : Nat, b : Nat, n : Nat) : Nat { let sum = a + b; @@ -47,11 +64,17 @@ module { }; // Compute a * b mod n. + /// Computes `(a * b) mod n`. + /// + /// Traps on division by zero when `n == 0`. public func mul(a : Nat, b : Nat, n : Nat) : Nat { (a * b) % n; }; // Compute a - b mod n. + /// Computes `(a - b) mod n`. + /// + /// Assumes `b < a + n`; never traps under that precondition. public func sub(a : Nat, b : Nat, n : Nat) : Nat { if (a >= b) { a - b; @@ -61,6 +84,9 @@ module { }; // Compute -a mod n. + /// Computes additive inverse `(-a) mod n`. + /// + /// Assumes `a <= n`; traps on `Nat` underflow when `a > n`. public func neg(a : Nat, n : Nat) : Nat { if (a == 0) { 0; diff --git a/src/ec/Fp.mo b/src/ec/Fp.mo index f7c76e2..d540abf 100644 --- a/src/ec/Fp.mo +++ b/src/ec/Fp.mo @@ -1,14 +1,35 @@ +/// Finite field element wrapper with modular arithmetic methods. +/// +/// ```motoko name=import +/// import Fp "mo:bitcoin/ec/Fp"; +/// ``` + import Runtime "mo:core/Runtime"; import Field "Field"; module { // Arithmetic computations modulo n over the given _value. + /// Field element over modulus `n`. + /// + /// Constructor arguments: + /// - `_value` — the integer value (will be reduced modulo `n`). + /// - `n` — the modulus. For a true prime field this should be prime; + /// methods like `inverse` and `sqrt` assume primality. + /// + /// Operations on `Fp` instances with different moduli produce undefined + /// results — callers must keep moduli consistent. public class Fp(_value : Nat, n : Nat) : Fp { + /// Canonical value reduced modulo `n`. public let value : Nat = _value % n; // Compute value ** -1 mod n. The inverse does not exist if _value and n are // not relatively prime. + /// Returns multiplicative inverse modulo `n`. + /// + /// Traps with `"unreachable"` when `value` is not coprime to `n` (in + /// particular when `value == 0`, or when `n` is composite and `value` + /// shares a factor with it). public func inverse() : Fp { let inverse : ?Nat = Field.inverse(value, n); switch inverse { @@ -22,27 +43,40 @@ module { }; // Compute value + other mod n. + /// Adds two field elements. public func add(other : Fp) : Fp = Fp(Field.add(value, other.value, n), n); // Compute value * other mod n. + /// Multiplies two field elements. public func mul(other : Fp) : Fp = Fp(Field.mul(value, other.value, n), n); // Compute value * 2 mod n. + /// Squares this field element. public func sqr() : Fp = Fp(Field.mul(value, value, n), n); // Compute value - other mod n. + /// Subtracts another field element. public func sub(other : Fp) : Fp = Fp(Field.sub(value, other.value, n), n); // Compute -value mod n. + /// Negates this field element. public func neg() : Fp = Fp(Field.neg(value, n), n); // Check equality with the given Fp object. + /// Checks value equality. public func isEqual(other : Fp) : Bool = other.value == value; // Compute value ** other mod n. + /// Raises this element to `exponent` modulo `n`. public func pow(exponent : Nat) : Fp = Fp(Field.pow(value, exponent, n), n); // Compute sqrt(value) mod n. + /// Computes a square root modulo `n` when one exists. + /// + /// Uses the Tonelli–Shanks shortcut for primes `n ≡ 3 (mod 4)` and + /// returns `value^((n+1)/4) mod n`. The result is only a valid square + /// root when one exists; callers are responsible for verifying that + /// `result.sqr().isEqual(self)`. Never traps. public func sqrt() : Fp { Fp(Field.pow(value, (n + 1) / 4, n), n); }; diff --git a/src/ec/Jacobi.mo b/src/ec/Jacobi.mo index 44cfbf8..009d1c0 100644 --- a/src/ec/Jacobi.mo +++ b/src/ec/Jacobi.mo @@ -1,3 +1,12 @@ +/// Jacobian-coordinate elliptic curve operations. +/// +/// Intended for public-data operations only; not constant-time for secret +/// inputs. +/// +/// ```motoko name=import +/// import Jacobi "mo:bitcoin/ec/Jacobi"; +/// ``` + // EC operations using Jacobian coordinates. // // This implementation is intended for use within Internet Computer canisters @@ -18,11 +27,13 @@ import Numbers "Numbers"; module { type Fp = BaseFp.Fp; + /// Jacobian point representation. public type Point = { #infinity : Curves.Curve; #point : (Fp, Fp, Fp, Curves.Curve); }; + /// Decodes SEC1 bytes into a Jacobian point on `curve`. // Deserialize given data into a point on the given curve. This supports // compressed and uncompressed SEC-1 formats. // Returns null if data is not in correct format, data size is not exactly @@ -36,17 +47,17 @@ module { }; }; - // Serialize given point to bytes in SEC-1 format. + /// Encodes a Jacobian point into SEC1 bytes. public func toBytes(point : Point, compressed : Bool) : [Nat8] { Affine.toBytes(toAffine(point), compressed); }; - // Check if the given point is valid. + /// Checks whether a Jacobian point lies on its curve. public func isOnCurve(point : Point) : Bool { Affine.isOnCurve(toAffine(point)); }; - // Convert given point from affine coordinates to jacobi coordinates + /// Converts an affine point to Jacobian coordinates. public func fromAffine(point : Affine.Point) : Point { switch point { case (#infinity(curve)) #infinity(curve); @@ -54,18 +65,18 @@ module { }; }; - // Create a jacobi point from the given coordinates. + /// Creates a Jacobian point from raw coordinates. // Returns null if the point is not valid. public func fromNat(x : Nat, y : Nat, z : Nat, curve : Curves.Curve) : ?Point { ?(#point(curve.Fp(x), curve.Fp(y), curve.Fp(z), curve)); }; - // Return the base point of the given curve. + /// Returns the generator point for `curve`. public func base(curve : Curves.Curve) : Point { #point(curve.Fp(curve.gx), curve.Fp(curve.gy), curve.Fp(1), curve); }; - // Check if the two given jacobi points are equal. + /// Compares two Jacobian points for equality. public func isEqual(point1 : Point, point2 : Point) : Bool { switch (normalizeInfinity(point1), normalizeInfinity(point2)) { case (#infinity(curve1), #infinity(curve2)) { @@ -85,7 +96,7 @@ module { }; }; - // Check if the given point is the point at infinity. + /// Returns true if `point` is at infinity. public func isInfinity(point : Point) : Bool { switch point { case (#infinity(_)) true; @@ -93,7 +104,7 @@ module { }; }; - // Convert the given jacobi point to affine. + /// Converts a Jacobian point to affine coordinates. public func toAffine(point : Point) : Affine.Point { let scaledPoint = scale(point); switch scaledPoint { @@ -102,7 +113,7 @@ module { }; }; - // Invert the given point on the x-axis. + /// Negates a point. public func neg(point : Point) : Point { switch (normalizeInfinity(point)) { case (#infinity(curve)) #infinity(curve); @@ -110,7 +121,7 @@ module { }; }; - // Normalize the given point such that z = 1. + /// Normalizes a Jacobian point so that `z = 1`. public func scale(point : Point) : Point { switch (normalizeInfinity(point)) { case (#infinity(curve)) #infinity(curve); @@ -131,7 +142,7 @@ module { }; }; - // Return double of the given point. + /// Returns point doubling result. public func double(point : Point) : Point { switch (normalizeInfinity(point)) { case (#infinity(curve)) #infinity(curve); @@ -147,7 +158,7 @@ module { }; }; - // Multiply the given point by the given scalar value. + /// Multiplies a point by scalar `other`. public func mul(point : Point, other : Nat) : Point { if (other == 0) { return #infinity(getCurve(point)); @@ -185,12 +196,16 @@ module { }; }; - // Multiply the base point of the given curve by the given scalar value. + /// Multiplies the curve generator by scalar `other`. public func mulBase(other : Nat, curve : Curves.Curve) : Point { mul(base(curve), other); }; - // Add the given two points. + /// Adds two Jacobian points. + /// + /// Traps with `"Cannot add two points on different curves"` when + /// `point1` and `point2` belong to different curves. Otherwise never + /// traps; identity, doubling, and infinity cases are handled. public func add(point1 : Point, point2 : Point) : Point { if (not Curves.isEqual(getCurve(point1), getCurve(point2))) { Runtime.trap("Cannot add two points on different curves"); diff --git a/src/ec/Numbers.mo b/src/ec/Numbers.mo index 86f98da..b4471a9 100644 --- a/src/ec/Numbers.mo +++ b/src/ec/Numbers.mo @@ -1,8 +1,17 @@ +/// Number theory helpers used by elliptic curve routines. +/// +/// ```motoko name=import +/// import Numbers "mo:bitcoin/ec/Numbers"; +/// ``` + import Array "mo:core/Array"; import List "mo:core/List"; module { // Extended Euclidean Algorithm. + /// Computes `(gcd, x, y)` such that `a*x + b*y = gcd`. + /// + /// Never traps. Returns `(a, 1, 0)` when `b == 0`. public func eea(a : Int, b : Int) : (Int, Int, Int) { if (b == 0) { return (a, 1, 0); @@ -13,6 +22,9 @@ module { // Convert given number to binary represented as an array of Bool in reverse // order. + /// Converts `a` to reversed bit order (least significant bit first). + /// + /// Returns the empty array `[]` when `a == 0`. Never traps. public func toBinaryReversed(a : Nat) : [Bool] { let bitsBuffer = List.empty(); var number : Nat = a; @@ -26,6 +38,9 @@ module { }; // Convert given number to binary represented as an array of Bool. + /// Converts `a` to bit array (most significant bit first). + /// + /// Returns the empty array `[]` when `a == 0`. Never traps. public func toBinary(a : Nat) : [Bool] { let reversedBinary = toBinaryReversed(a); Array.tabulate( @@ -37,6 +52,15 @@ module { }; // Compute the Non-adjacent form representiation of the given integer. + /// Computes the non-adjacent form (NAF) digits of `n`. + /// + /// NAF is a signed binary representation where each digit is in + /// `{-1, 0, 1}` and no two consecutive digits are non-zero. It is used + /// to speed up scalar multiplication on elliptic curves by reducing the + /// number of point additions. + /// + /// The result is least-significant-digit first. Returns the empty array + /// `[]` when `n == 0`. Never traps. public func toNaf(n : Int) : [Int] { var input : Int = n; let output = List.empty(); diff --git a/src/ecdsa/Der.mo b/src/ecdsa/Der.mo index 4fe384d..7bd1d90 100644 --- a/src/ecdsa/Der.mo +++ b/src/ecdsa/Der.mo @@ -1,3 +1,9 @@ +/// DER encoding and decoding for ECDSA signatures. +/// +/// ```motoko name=import +/// import Der "mo:bitcoin/ecdsa/Der"; +/// ``` + import Blob "mo:core/Blob"; import List "mo:core/List"; import Nat "mo:core/Nat"; @@ -93,6 +99,10 @@ module { Blob.fromArray(output.toArray()); }; + /// Encodes a raw `(r || s)` 64-byte signature into DER. + /// + /// Traps when `signature.size() < 64` (out-of-bounds copy of the raw + /// `r`/`s` bytes). // Accepts a Blob containing the concatenation of the 32-byte big endian // encodings of the two values r and s of the signature. // Outputs DER encoding of the signature: @@ -107,6 +117,26 @@ module { _encodeSignature(rdata.toArray(), sdata.toArray()); }; + /// Decodes a DER-encoded ECDSA signature into `(r, s)`. + /// + /// Never traps. Returns `#err(message)` when the DER structure is + /// malformed: + /// - `"Could not parse signature."` — missing `0x30` SEQUENCE tag, + /// missing total length, missing `0x02` INTEGER tag for `r`, or + /// missing `r` length. + /// - `"Could not parse r sequence."` — truncated `r` payload, missing + /// `0x02` INTEGER tag for `s`, or missing `s` length. + /// - `"Could not parse s sequence."` — truncated `s` payload. + /// - `"Invalid r size."` — `r` is empty or longer than 33 bytes. + /// - `"Invalid s size."` — `s` is empty or longer than 33 bytes. + /// - `"r value cannot be negative."` — `r` is 33 bytes but the leading + /// byte is not `0x00`. + /// - `"s value cannot be negative."` — `s` is 33 bytes but the leading + /// byte is not `0x00`. + /// - `"Wrong total length"` — declared total length does not match + /// `rLen + sLen + 4`. + /// - `"Did not consume all data"` — trailing bytes after the declared + /// structure. // Decode signature in DER format. // 0x30 [total-length] 0x02 [R-length] [R] 0x02 [S-length] [S] public func decodeSignature(signature : DerSignature) : Result { diff --git a/src/ecdsa/Ecdsa.mo b/src/ecdsa/Ecdsa.mo index 2926a7c..ac7ff86 100644 --- a/src/ecdsa/Ecdsa.mo +++ b/src/ecdsa/Ecdsa.mo @@ -1,3 +1,9 @@ +/// ECDSA signature verification utilities. +/// +/// ```motoko name=import +/// import Ecdsa "mo:bitcoin/ecdsa/Ecdsa"; +/// ``` + import Blob "mo:core/Blob"; import Sha256 "mo:sha2/Sha256"; @@ -8,10 +14,23 @@ import Jacobi "../ec/Jacobi"; import Types "Types"; module { + /// ECDSA signature type re-export. public type Signature = Types.Signature; + /// ECDSA public key type re-export. public type PublicKey = Types.PublicKey; // Verify ECDSA signature using SHA256 as a hash function. + /// Verifies an ECDSA signature for `message` using SHA-256 prehashing. + /// + /// Never traps. Returns `false` when: + /// - `signature.r == 0` or `signature.s == 0`, + /// - `signature.r >= curve.r` or `signature.s >= curve.r`, + /// - the recovered point is at infinity, or its `x` coordinate (mod + /// `curve.r`) does not equal `signature.r`. + /// + /// Returns `true` only when the signature is valid for `message` under + /// `publicKey`. The `publicKey` is assumed to have been validated by + /// `Publickey.decode` (on-curve, not at infinity). public func verify( signature : Signature, publicKey : PublicKey, diff --git a/src/ecdsa/Publickey.mo b/src/ecdsa/Publickey.mo index 48ef7ff..45dc585 100644 --- a/src/ecdsa/Publickey.mo +++ b/src/ecdsa/Publickey.mo @@ -1,3 +1,9 @@ +/// Public key decoding and SEC1 conversion utilities. +/// +/// ```motoko name=import +/// import Publickey "mo:bitcoin/ecdsa/Publickey"; +/// ``` + import { type Result } "mo:core/Types"; import Affine "../ec/Affine"; @@ -8,7 +14,16 @@ module { type PublicKey = Types.PublicKey; type EncodedPublicKey = Types.EncodedPublicKey; - // Decode a public key from several possible forms. + /// Decodes a public key from encoded point or SEC1 bytes. + /// + /// Never traps. Returns `#err(message)` when: + /// - the input is `#sec1` and `Affine.fromBytes` fails (size mismatch, + /// bad leading byte, or off-curve point) — + /// `"Could not deserialize data."`, + /// - the decoded point is at infinity — + /// `"Can't create public key from point at infinity."`, + /// - the input is `#point` and the point is not on its curve — + /// `"Point not on curve."`. public func decode(pk : EncodedPublicKey) : Result { switch (pk) { case (#point(point)) { @@ -60,7 +75,13 @@ module { }; }; - // Converts given public key to SEC1 format. + /// Encodes a public key to compressed or uncompressed SEC1 form. + /// + /// `compressed = true` returns 33 bytes (`0x02`/`0x03` prefix + 32-byte x). + /// `compressed = false` returns 65 bytes (`0x04` prefix + 32-byte x + 32-byte y). + /// + /// Never traps. The returned tuple pairs the SEC1 byte encoding with the + /// curve the key belongs to. public func toSec1( pk : PublicKey, compressed : Bool, diff --git a/src/ecdsa/Types.mo b/src/ecdsa/Types.mo index 14600e9..70e4dd0 100644 --- a/src/ecdsa/Types.mo +++ b/src/ecdsa/Types.mo @@ -1,9 +1,23 @@ +/// Shared ECDSA type aliases and records. +/// +/// ```motoko name=import +/// import Types "mo:bitcoin/ecdsa/Types"; +/// ``` + import Affine "../ec/Affine"; import Curves "../ec/Curves"; import Fp "../ec/Fp"; module { + /// ECDSA private key scalar. + /// + /// A `Nat` interpreted as a 256-bit big-endian integer in + /// `[1, n)` where `n` is the curve order. public type PrivateKey = Nat; + /// ECDSA public key as an affine point on a curve. + /// + /// `coords` are the affine `(x, y)` coordinates as field elements. + /// `curve` identifies the curve the point lies on. public type PublicKey = { coords : { x : Fp.Fp; @@ -12,14 +26,24 @@ module { curve : Curves.Curve; }; + /// SEC1-encoded public key bytes paired with the curve. + /// + /// The byte array is either 33 bytes (compressed: leading `0x02`/`0x03` + /// followed by the 32-byte x coordinate) or 65 bytes (uncompressed: + /// leading `0x04` followed by 32-byte x and y coordinates). public type Sec1PublicKey = ([Nat8], Curves.Curve); + /// Public key payload accepted by decoding APIs. public type EncodedPublicKey = { #sec1 : Sec1PublicKey; #point : Affine.Point; }; + /// ECDSA signature `(r, s)` scalars (raw, not DER-encoded). public type Signature = { r : Nat; s : Nat }; + /// ASN.1 DER encoded signature blob (the form used inside Bitcoin + /// scriptSigs, before the trailing sighash type byte). public type DerSignature = Blob; + /// Signature payload accepted by decoding APIs. public type EncodedSignature = { #der : DerSignature; }; From 18cdbc972f438d81e95e047723ca45236f7cc804 Mon Sep 17 00:00:00 2001 From: andy Date: Thu, 23 Apr 2026 14:38:17 +0300 Subject: [PATCH 02/17] Improve error handling and clarify documentation across various modules. --- src/Bech32.mo | 9 +++++++-- src/Hmac.mo | 16 ++++++++-------- src/Segwit.mo | 8 +++++++- src/bitcoin/Address.mo | 2 +- src/bitcoin/Transaction.mo | 10 ++++++++-- src/ec/Fp.mo | 20 ++++++++++++++------ src/ec/Jacobi.mo | 1 - 7 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/Bech32.mo b/src/Bech32.mo index aa3dc89..c3ee69f 100644 --- a/src/Bech32.mo +++ b/src/Bech32.mo @@ -138,8 +138,13 @@ module { /// distinct error categories are: /// - `"Found unexpected character: ..."` — `input` contains a byte /// outside the printable ASCII range `'!'`..`'~'`. - /// - `"Inconsistent character casing in HRP."` — `input` mixes upper- - /// and lowercase letters (Bech32 forbids mixed case). + /// - `"Inconsistent character casing in HRP."` — the entire `input` string + /// mixes uppercase and lowercase letters (the `lowercase` and `uppercase` + /// flags are set during the initial scan of all characters in `input` + /// before the separator is located), or there are no alphabetical + /// characters at all in `input`. The Bech32 standard forbids mixed case + /// across the entire string, and the HRP is extracted later after this + /// validation passes. /// - `"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. diff --git a/src/Hmac.mo b/src/Hmac.mo index aba626d..7b4db55 100644 --- a/src/Hmac.mo +++ b/src/Hmac.mo @@ -40,6 +40,10 @@ module { sum : () -> Blob; }; + object sha256DigestFactory { + public let blockSize : Nat = 64; + public func create() : Digest = Sha256.Digest(#sha256); + }; /// Creates an HMAC-SHA256 instance with the given `key`. /// /// Example: @@ -52,12 +56,12 @@ module { /// Never traps. Accepts a `key` of any length, including the empty key. /// Subsequent `writeArray` and `sum` calls also never trap. // Sha256 support. - object sha256DigestFactory { - public let blockSize : Nat = 64; - public func create() : Digest = Sha256.Digest(#sha256); - }; public func sha256(key : [Nat8]) : Hmac = HmacImpl(key, sha256DigestFactory); + object sha512DigestFactory { + public let blockSize : Nat = 128; + public func create() : Digest = Sha512.Digest(#sha512); + }; /// Creates an HMAC-SHA512 instance with the given `key`. /// /// Example: @@ -70,10 +74,6 @@ module { /// Never traps. Accepts a `key` of any length, including the empty key. /// Subsequent `writeArray` and `sum` calls also never trap. // Sha512 support. - object sha512DigestFactory { - public let blockSize : Nat = 128; - public func create() : Digest = Sha512.Digest(#sha512); - }; public func sha512(key : [Nat8]) : Hmac = HmacImpl(key, sha512DigestFactory); /// Creates an HMAC instance using a custom digest factory. diff --git a/src/Segwit.mo b/src/Segwit.mo index eb6ce5e..484b1a4 100644 --- a/src/Segwit.mo +++ b/src/Segwit.mo @@ -42,7 +42,13 @@ module { /// /// Example: /// ```motoko include=import - /// let result = Segwit.encode("bc", { version = 0; program = [0x00] }); + /// let result = Segwit.encode("bc", { + /// version = 0; + /// program = [ + /// 0x75, 0x1e, 0x76, 0xe8, 0x19, 0x91, 0x96, 0xd4, 0x54, 0x94, + /// 0x1c, 0x45, 0xd1, 0xb3, 0xa3, 0x23, 0xf1, 0x43, 0x3b, 0xd6, + /// ]; + /// }); /// ``` /// /// Returns `#err(message)` when the bit-group conversion of `program` diff --git a/src/bitcoin/Address.mo b/src/bitcoin/Address.mo index 96154dc..df5cef6 100644 --- a/src/bitcoin/Address.mo +++ b/src/bitcoin/Address.mo @@ -27,7 +27,7 @@ module { /// the address text or call `Segwit.decode` directly if you need to /// distinguish them. /// - /// Never traps. Returns `#err("Failed to decode address ...")` when the + /// Returns `#err("Failed to decode address ...")` when the /// input is neither a valid SegWit nor a valid P2PKH address. public func addressFromText(address : Text) : Result { switch (Segwit.decode(address)) { diff --git a/src/bitcoin/Transaction.mo b/src/bitcoin/Transaction.mo index a35179e..d993487 100644 --- a/src/bitcoin/Transaction.mo +++ b/src/bitcoin/Transaction.mo @@ -32,8 +32,14 @@ module { /// Never traps. Returns `#err(message)` when the byte stream is /// malformed (e.g. truncated header, invalid varint sizes, malformed /// inputs, outputs, witnesses or scripts). Specific messages include - /// `"Could not read version."`, `"Could not read txInCount."`, - /// `"Could not read txOutCount."`, `"Could not read locktime."`, + /// `"Could not read version."`, `"Invalid witness flag."`, + /// `"Could not read TxInputs size in a transaction with witness."`, + /// `"Could not read TxInputs size in a transaction without witness."`, + /// `"Could not deserialize TxInput: "`, + /// `"Could not read TxOutputs size."`, + /// `"Could not deserialize TxOutput: "`, + /// `"Could not deserialize Witness: "`, + /// `"Could not read locktime."`, /// and errors propagated from `TxInput.fromBytes`, `TxOutput.fromBytes`, /// `Witness.fromBytes`, and `Script.fromBytes`. public func fromBytes(data : Iter) : Result { diff --git a/src/ec/Fp.mo b/src/ec/Fp.mo index d540abf..a71ffcb 100644 --- a/src/ec/Fp.mo +++ b/src/ec/Fp.mo @@ -70,13 +70,21 @@ module { /// Raises this element to `exponent` modulo `n`. public func pow(exponent : Nat) : Fp = Fp(Field.pow(value, exponent, n), n); - // Compute sqrt(value) mod n. - /// Computes a square root modulo `n` when one exists. + /// Computes a square root of `value` modulo `n` for the special case + /// where `n` is a prime and `n ≡ 3 (mod 4)`. /// - /// Uses the Tonelli–Shanks shortcut for primes `n ≡ 3 (mod 4)` and - /// returns `value^((n+1)/4) mod n`. The result is only a valid square - /// root when one exists; callers are responsible for verifying that - /// `result.sqr().isEqual(self)`. Never traps. + /// In this case, the square root (when it exists) can be computed as + /// `value^((n + 1) / 4) mod n`, which is a shortcut of the + /// Tonelli–Shanks algorithm. + /// + /// Note: + /// - This function does not check whether a square root exists. + /// - The returned value is only valid if `value` is a quadratic residue mod `n`. + /// - Callers should verify the result by checking `result.sqr().isEqual(value)`, + /// where `result` is the returned Fp value and `value` is the Fp instance on + /// which sqrt() was called. + /// + /// Never traps. public func sqrt() : Fp { Fp(Field.pow(value, (n + 1) / 4, n), n); }; diff --git a/src/ec/Jacobi.mo b/src/ec/Jacobi.mo index 009d1c0..fc951b4 100644 --- a/src/ec/Jacobi.mo +++ b/src/ec/Jacobi.mo @@ -66,7 +66,6 @@ module { }; /// Creates a Jacobian point from raw coordinates. - // Returns null if the point is not valid. public func fromNat(x : Nat, y : Nat, z : Nat, curve : Curves.Curve) : ?Point { ?(#point(curve.Fp(x), curve.Fp(y), curve.Fp(z), curve)); }; From e5b350b584046f682b4f6064449453181ade257b Mon Sep 17 00:00:00 2001 From: andy Date: Thu, 23 Apr 2026 14:45:10 +0300 Subject: [PATCH 03/17] Clarify documentation for SegWit address encoding and update example in decode function. --- src/Segwit.mo | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Segwit.mo b/src/Segwit.mo index 484b1a4..4cffc62 100644 --- a/src/Segwit.mo +++ b/src/Segwit.mo @@ -58,7 +58,6 @@ module { /// Traps if `hrp` is empty, contains characters outside `'!'`..`'~'`, /// contains uppercase letters, or if the resulting Bech32 string would /// exceed 90 characters — these are inherited from `Bech32.encode`. - // Convert a Witness Program to a SegWit Address. public func encode(hrp : Text, { version; program } : WitnessProgram) : Result { let converted = switch (convertBits(program, 0, 8, 5, true)) { @@ -97,7 +96,7 @@ module { /// /// Example: /// ```motoko include=import - /// let decoded = Segwit.decode("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"); + /// let decoded = Segwit.decode("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"); /// ``` /// /// Never traps. Returns `#err(message)` with one of: From 5db572d9d9b6b576b10c3f0579e687e3fc7b255e Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 16:45:14 +0200 Subject: [PATCH 04/17] Add argument checks in Segwit.encode() suggested by CodeRabbit witness version <= 16 program length in [2,40] program length 20 or 32 for version 0 --- src/Segwit.mo | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Segwit.mo b/src/Segwit.mo index 4cffc62..d3e4da5 100644 --- a/src/Segwit.mo +++ b/src/Segwit.mo @@ -60,6 +60,19 @@ module { /// exceed 90 characters — these are inherited from `Bech32.encode`. public func encode(hrp : Text, { version; program } : WitnessProgram) : Result { + if (version > 16) { + return #err("Invalid witness version."); + }; + + let programSize = program.size(); + if (programSize < 2 or programSize > 40) { + return #err("Wrong output size."); + }; + + if (version == 0 and programSize != 20 and programSize != 32) { + return #err("Program size does not match witness version."); + }; + let converted = switch (convertBits(program, 0, 8, 5, true)) { case (#err(msg)) return #err(msg); case (#ok(c)) c; From 38f69e0c689173a0e35b0cb65af3d656ac0a1a82 Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 16:53:40 +0200 Subject: [PATCH 05/17] Add check in Wif.decode for invalid scalars (by CodeRabbit) Include unit tests for invalid scalars --- src/bitcoin/Wif.mo | 12 ++++++++++-- test/bitcoin/wif.test.mo | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/bitcoin/Wif.mo b/src/bitcoin/Wif.mo index af049ef..3686c9b 100644 --- a/src/bitcoin/Wif.mo +++ b/src/bitcoin/Wif.mo @@ -9,6 +9,7 @@ import { type Iter; type Result } "mo:core/Types"; import Base58Check "../Base58Check"; import ByteUtils "../ByteUtils"; import Common "../Common"; +import Curves "../ec/Curves"; import Types "Types"; module { @@ -56,7 +57,9 @@ module { /// - the payload is not exactly `version || 32-byte key [|| 0x01]` — /// `"Invalid key format."`, /// - the version byte is not `0x80` (mainnet) or `0xef` (testnet/regtest) - /// — `"Unknown network version."`. + /// — `"Unknown network version."`, + /// - the decoded scalar is `0` or `≥` the secp256k1 group order — + /// `"Invalid private scalar."`. /// /// Traps if the Base58 payload is shorter than 4 bytes (inherited from /// `Base58Check.decode`). @@ -97,9 +100,14 @@ module { }; }; + let privateKey = Common.readBE256(data, 0); + if (privateKey == 0 or privateKey >= Curves.secp256k1.r) { + return #err("Invalid private scalar."); + }; + return #ok({ network = network; - key = Common.readBE256(data, 0); + key = privateKey; compressedPublicKey = compressed; }); }; diff --git a/test/bitcoin/wif.test.mo b/test/bitcoin/wif.test.mo index cd7a67f..11a1768 100644 --- a/test/bitcoin/wif.test.mo +++ b/test/bitcoin/wif.test.mo @@ -90,6 +90,26 @@ let invalidWifTestCases : [InvalidWifTestCase] = [ // Invalid length. wif = "38uMpGARR2BJy5p4dNFKYg9UsWNoBtkpbdrXDjmfvz8krCtw3T1W92ZDSR"; }, + { + // Zero scalar (mainnet, compressed). + wif = "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73Nd2Mcv1"; + }, + { + // Zero scalar (mainnet, uncompressed). + wif = "5HpHagT65TZzG1PH3CSu63k8DbpvD8s5ip4nEB3kEsreAbuatmU"; + }, + { + // Zero scalar (testnet, compressed). + wif = "cMahea7zqjxrtgAbB7LSGbcQUr1uX1ojuat9jZodMN87J7g8rY9t"; + }, + { + // Scalar equal to secp256k1 group order r (mainnet, compressed). + wif = "L5oLkpV3aqBjhki6LmvChTCV6odsp4SXM6FfU2Gppt5kFqRzExJJ"; + }, + { + // Scalar equal to r + 1 (mainnet, uncompressed). + wif = "5Km2kuu7vtFDPpxywn4u3NLpbr5jKpTB3jsuDU2KYEqetyuszSh"; + }, ]; func testValidWifDecode(tcase : ValidWifTestCase) { From 3e1c8de5cf353c14231313fbfbe759d6bfb1bd01 Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 16:58:48 +0200 Subject: [PATCH 06/17] Fix comment about field prime (by CodeRabbit) --- src/bitcoin/P2tr.mo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bitcoin/P2tr.mo b/src/bitcoin/P2tr.mo index 94f203a..1a167b5 100644 --- a/src/bitcoin/P2tr.mo +++ b/src/bitcoin/P2tr.mo @@ -109,7 +109,7 @@ module { let tweak = Common.readBE256(tagged_hash, 0); if (tweak >= Curves.secp256k1.p) { - return #err("Failed to compute tweak, tweak is not smaller than the curve order"); + return #err("Failed to compute tweak, tweak is not smaller than the field prime"); }; #ok(Curves.secp256k1.Fp(tweak)); From afbed9c08141e38e091c2f5a50160aca217e957f Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 17:10:44 +0200 Subject: [PATCH 07/17] Add tests for Bech32.decode that currently fail, exposing a bug The test vectors consist exclusively of numerical characters --- test/bech32.test.mo | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/bech32.test.mo b/test/bech32.test.mo index cd34236..9e50321 100644 --- a/test/bech32.test.mo +++ b/test/bech32.test.mo @@ -15,6 +15,11 @@ let validChecksumBech32 : [Text] = [ "11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", "split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", "?1ezyfcl", + // HRP, separator, and data section all consist solely of numeric + // characters (no alphabetic characters anywhere in the string). The + // BECH32 standard does not require alphabetic characters; an all-digit + // string is unambiguously single-case. + "012854464896", ]; let validChecksumBech32m : [Text] = [ @@ -25,6 +30,11 @@ let validChecksumBech32m : [Text] = [ "11llllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllludsr8", "split1checkupstagehandshakeupstreamerranterredcaperredlc445v", "?1v759aa", + // HRP, separator, and data section all consist solely of numeric + // characters (no alphabetic characters anywhere in the string). The + // BECH32M standard does not require alphabetic characters; an all-digit + // string is unambiguously single-case. + "012343952083", ]; let invalidChecksumBech32 : [Text] = [ From fdbda5b0b529e84908757b86cfbd5870539f9376 Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 17:18:54 +0200 Subject: [PATCH 08/17] Make Bech32.decode accept an all-numeric character string --- src/Bech32.mo | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Bech32.mo b/src/Bech32.mo index c3ee69f..2d40e23 100644 --- a/src/Bech32.mo +++ b/src/Bech32.mo @@ -138,13 +138,9 @@ module { /// distinct error categories are: /// - `"Found unexpected character: ..."` — `input` contains a byte /// outside the printable ASCII range `'!'`..`'~'`. - /// - `"Inconsistent character casing in HRP."` — the entire `input` string - /// mixes uppercase and lowercase letters (the `lowercase` and `uppercase` - /// flags are set during the initial scan of all characters in `input` - /// before the separator is located), or there are no alphabetical - /// characters at all in `input`. The Bech32 standard forbids mixed case - /// across the entire string, and the HRP is extracted later after this - /// validation passes. + /// - `"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. @@ -178,7 +174,7 @@ module { }; - if (lowercase == uppercase) { + if (lowercase and uppercase) { return #err("Inconsistent character casing in HRP."); }; From d82c7a9d536b3f639862f6bad256911ab0a354f1 Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 17:32:45 +0200 Subject: [PATCH 09/17] Avoid trap in Base58Check.decode on too short input --- src/Base58Check.mo | 19 ++++++++++++------- test/base58Check.test.mo | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/Base58Check.mo b/src/Base58Check.mo index 4b71571..0e0955d 100755 --- a/src/Base58Check.mo +++ b/src/Base58Check.mo @@ -58,17 +58,22 @@ module { /// let decoded = Base58Check.decode("1AGNa15ZQXAZUgFiqJ2i7Z2DPU2J6hW62i"); /// ``` /// - /// Returns `null` when the trailing 4 checksum bytes do not match the - /// double-SHA256 of the payload. + /// 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 the Base58 decoding of `input` produces fewer than 4 bytes - /// (via `Nat` underflow when stripping the checksum) or 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. + /// 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( decoded.size() - 4, diff --git a/test/base58Check.test.mo b/test/base58Check.test.mo index 4e45520..a81c068 100644 --- a/test/base58Check.test.mo +++ b/test/base58Check.test.mo @@ -121,6 +121,21 @@ let testData : [(?[Nat8], Text)] = [ null, "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHL", ), + ( + // Empty Base58 string decodes to 0 bytes — too short for a 4-byte checksum. + null, + "", + ), + ( + // "1" decodes to a single zero byte — too short for a 4-byte checksum. + null, + "1", + ), + ( + // "111" decodes to three zero bytes — too short for a 4-byte checksum. + null, + "111", + ), ( // prettier-ignore ?[ @@ -182,3 +197,15 @@ test( }; }, ); + +// Boundary case: input Base58-decodes to exactly 4 bytes (the checksum +// alone) and the payload is empty. This is the smallest valid Base58Check +// string. "3QJmnh" Base58-decodes to the 4-byte double-SHA256 prefix of +// the empty byte string. +test( + "decode empty payload (checksum-only input)", + func() { + let actual = Base58Check.decode("3QJmnh"); + assert (actual == ?([] : [Nat8])); + }, +); From cbdc26dad84fac29cc47956be78d93356ddd9652 Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 17:40:45 +0200 Subject: [PATCH 10/17] Add failing test which should succeed according to doc string --- test/bitcoin/p2pkh.test.mo | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/bitcoin/p2pkh.test.mo b/test/bitcoin/p2pkh.test.mo index 855d9b3..b0f5b8f 100644 --- a/test/bitcoin/p2pkh.test.mo +++ b/test/bitcoin/p2pkh.test.mo @@ -104,3 +104,40 @@ runTest({ fn = testMakeScript; vectors = makeScriptTestCases; }); + +// Per the doc string of `P2pkh.decodeAddress`, an address with an invalid +// Base58 alphabet character (e.g. `0`, `O`, `I`, `l`) should result in +// `#err("Could not base58 decode address.")`. With the current +// implementation this traps instead, because `Base58.decode` traps on +// non-alphabet characters and `Base58Check.decode` does not catch it. +type InvalidAlphabetTestCase = { + address : Text; +}; + +let invalidAlphabetTestCases : [InvalidAlphabetTestCase] = [ + // Valid mainnet address with one character replaced by '0' (not in alphabet). + { address = "0MmqjDhakEfJd9r5BoDhPApCpA75Em17GA" }, + // Valid mainnet address with one character replaced by 'O' (not in alphabet). + { address = "1MmqjDhakEfJd9r5BoDhPApCpA75Em17GO" }, + // Valid mainnet address with one character replaced by 'l' (not in alphabet). + { address = "1MmqjDhakEfJd9r5BoDhPApCpA75Em17Gl" }, + // Valid mainnet address with one character replaced by 'I' (not in alphabet). + { address = "1MmqjDhakEfJd9r5BoDhPApCpA75Em17GI" }, +]; + +func testP2pkhDecodeAddressInvalidAlphabet(testCase : InvalidAlphabetTestCase) { + switch (P2pkh.decodeAddress(testCase.address)) { + case (#err msg) { + assert (msg == "Could not base58 decode address."); + }; + case (#ok _) { + Runtime.trap("Expected #err for address with invalid alphabet character."); + }; + }; +}; + +runTest({ + title = "Decode P2PKH address with invalid alphabet character"; + fn = testP2pkhDecodeAddressInvalidAlphabet; + vectors = invalidAlphabetTestCases; +}); From 07c4ee83c5ad9cd475c005266fb47be2bcda5e9b Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 17:51:17 +0200 Subject: [PATCH 11/17] Make test pass again by avoiding internal traps --- src/Base58.mo | 67 ++++++++++++++++++++++++++++++++++++++++++++ src/bitcoin/P2pkh.mo | 11 ++++---- test/base58.test.mo | 60 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 6 deletions(-) diff --git a/src/Base58.mo b/src/Base58.mo index ce292bf..24e91e2 100755 --- a/src/Base58.mo +++ b/src/Base58.mo @@ -56,6 +56,73 @@ module { }; }; + /// 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 diff --git a/src/bitcoin/P2pkh.mo b/src/bitcoin/P2pkh.mo index a155cc1..70c91aa 100644 --- a/src/bitcoin/P2pkh.mo +++ b/src/bitcoin/P2pkh.mo @@ -7,6 +7,7 @@ import Array "mo:core/Array"; import { type Result; type Iter } "mo:core/Types"; +import Base58 "../Base58"; import Base58Check "../Base58Check"; import ByteUtils "../ByteUtils"; import Hash "../Hash"; @@ -37,9 +38,6 @@ module { /// /// Returns `#err(message)` propagated from `decodeAddress` (see that /// function for the exact error categories). - /// - /// Traps if Base58 decoding of `address` would underflow because the - /// payload is shorter than 4 bytes (inherited from `Base58Check.decode`). public func makeScript(address : Address) : Result { switch (decodeAddress(address)) { case (#ok { network = _; publicKeyHash }) { @@ -103,11 +101,12 @@ module { /// — `"Unrecognized network id."`, /// - the decoded payload does not contain a version byte followed by /// exactly 20 hash bytes — `"Could not decode address."`. - /// - /// Traps if the Base58 payload is shorter than 4 bytes (inherited from - /// `Base58Check.decode`). public func decodeAddress(address : Address) : Result { + if (not Base58.isBase58Alphabet(address)) { + return #err("Could not base58 decode address."); + }; + let decoded : Iter = switch (Base58Check.decode(address)) { case (?b58decoded) { b58decoded.values(); diff --git a/test/base58.test.mo b/test/base58.test.mo index f26d357..01453a8 100644 --- a/test/base58.test.mo +++ b/test/base58.test.mo @@ -169,3 +169,63 @@ test( }; }, ); + +test( + "isBase58Alphabet accepts valid alphabet", + func() { + assert (Base58.isBase58Alphabet("")); + assert (Base58.isBase58Alphabet(" ")); + assert (Base58.isBase58Alphabet("1")); + assert (Base58.isBase58Alphabet("2g")); + assert (Base58.isBase58Alphabet("a3gV")); + assert (Base58.isBase58Alphabet("1AGNa15ZQXAZUgFiqJ2i7Z2DPU2J6hW62i")); + // Leading/trailing spaces are allowed. + assert (Base58.isBase58Alphabet(" 1AGNa15ZQXAZUgFiqJ2i7Z2DPU2J6hW62i")); + assert (Base58.isBase58Alphabet("1AGNa15ZQXAZUgFiqJ2i7Z2DPU2J6hW62i ")); + assert (Base58.isBase58Alphabet(" 1AGNa15ZQXAZUgFiqJ2i7Z2DPU2J6hW62i ")); + }, +); + +test( + "isBase58Alphabet rejects invalid characters", + func() { + // Each of the four characters explicitly excluded from the alphabet. + assert (not Base58.isBase58Alphabet("0")); + assert (not Base58.isBase58Alphabet("O")); + assert (not Base58.isBase58Alphabet("I")); + assert (not Base58.isBase58Alphabet("l")); + // Embedded space is not allowed. + assert (not Base58.isBase58Alphabet("1AGN 15ZQXAZUgFiqJ2i7Z2DPU2J6hW62i")); + // Other punctuation / non-alphabet ASCII. + assert (not Base58.isBase58Alphabet("hello!")); + assert (not Base58.isBase58Alphabet("1+2")); + // Non-ASCII bytes (UTF-8 encoded multibyte). + assert (not Base58.isBase58Alphabet("é")); + }, +); + +test( + "decodeOpt returns ?bytes for valid input", + func() { + for (i in Nat.range(0, testData.size())) { + let input = testData[i].1; + let expected = testData[i].0; + switch (Base58.decodeOpt(input)) { + case (?actual) { assert (expected == actual) }; + case null { assert false }; + }; + }; + }, +); + +test( + "decodeOpt returns null for invalid alphabet", + func() { + assert (Base58.decodeOpt("0") == null); + assert (Base58.decodeOpt("O") == null); + assert (Base58.decodeOpt("I") == null); + assert (Base58.decodeOpt("l") == null); + assert (Base58.decodeOpt("1MmqjDhakEfJd9r5BoDhPApCpA75Em17G0") == null); + assert (Base58.decodeOpt("hello!") == null); + }, +); From 72b6482782cbe5ece110af4878c9f6fdcac157d8 Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 20:38:11 +0200 Subject: [PATCH 12/17] Make bitcoin/Wif.decode non-trapping --- src/bitcoin/Wif.mo | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/bitcoin/Wif.mo b/src/bitcoin/Wif.mo index 3686c9b..88ec5d7 100644 --- a/src/bitcoin/Wif.mo +++ b/src/bitcoin/Wif.mo @@ -6,6 +6,7 @@ import { type Iter; type Result } "mo:core/Types"; +import Base58 "../Base58"; import Base58Check "../Base58Check"; import ByteUtils "../ByteUtils"; import Common "../Common"; @@ -61,19 +62,23 @@ module { /// - the decoded scalar is `0` or `≥` the secp256k1 group order — /// `"Invalid private scalar."`. /// - /// Traps if the Base58 payload is shorter than 4 bytes (inherited from - /// `Base58Check.decode`). // Decode WIF private key to extract network, private key, // and compression flag. public func decode(key : WifPrivateKey) : Result { + + if (not Base58.isBase58Alphabet(key)) { + return #err("Could not base58 decode address."); + }; + let decoded : Iter = switch (Base58Check.decode(key)) { case (?b58decoded) { b58decoded.values(); }; - case _ { + case (null) { return #err("Could not base58 decode key."); }; }; + // Split into version || data || compressed. let (version, data, compressed) : (Nat8, [Nat8], Bool) = switch ( decoded.next(), From 3c473e5c011cda9e92b3965435670319beba41c6 Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 21:12:29 +0200 Subject: [PATCH 13/17] Fix small CodeRabbit comments --- src/bitcoin/Transaction.mo | 2 -- src/ec/Jacobi.mo | 4 ++-- test/ec/jacobi.test.mo | 5 +---- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/bitcoin/Transaction.mo b/src/bitcoin/Transaction.mo index d993487..d6e6d2d 100644 --- a/src/bitcoin/Transaction.mo +++ b/src/bitcoin/Transaction.mo @@ -268,8 +268,6 @@ module { /// /// Traps when: /// - `txInputIndex.toNat() >= txInputs.size()` (out-of-bounds index), - /// - `amounts.size() != txInputs.size()` (out-of-bounds index inside - /// the SHA-amounts loop), /// - any contained `Script` has a `#data` element larger than /// `2^32 - 1` bytes (inherited from `Script.toBytes`). public func createTaprootKeySpendSignatureHash( diff --git a/src/ec/Jacobi.mo b/src/ec/Jacobi.mo index fc951b4..bc9b94d 100644 --- a/src/ec/Jacobi.mo +++ b/src/ec/Jacobi.mo @@ -66,8 +66,8 @@ module { }; /// Creates a Jacobian point from raw coordinates. - public func fromNat(x : Nat, y : Nat, z : Nat, curve : Curves.Curve) : ?Point { - ?(#point(curve.Fp(x), curve.Fp(y), curve.Fp(z), curve)); + public func fromNat(x : Nat, y : Nat, z : Nat, curve : Curves.Curve) : Point { + #point(curve.Fp(x), curve.Fp(y), curve.Fp(z), curve); }; /// Returns the generator point for `curve`. diff --git a/test/ec/jacobi.test.mo b/test/ec/jacobi.test.mo index c11371b..a27c4bf 100644 --- a/test/ec/jacobi.test.mo +++ b/test/ec/jacobi.test.mo @@ -23,10 +23,7 @@ let runTest = TestUtils.runTestWithDefaults; func getSecp256k1Point(coords : ?(Nat, Nat)) : Jacobi.Point { switch (coords) { case (?(x, y)) { - switch (Jacobi.fromNat(x, y, 1, Curves.secp256k1)) { - case (null) Runtime.trap("unreachable"); - case (?point) point; - }; + Jacobi.fromNat(x, y, 1, Curves.secp256k1); }; case (null) #infinity(Curves.secp256k1); }; From 0b75426374027211eebd3d0ed79571f7c13745f2 Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 21:22:29 +0200 Subject: [PATCH 14/17] Update CHANGELOG --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff96738..c2335a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ 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() + ## [0.2.0] - 2026-04-22 ### Changed From 58f5bdc9c9f69cd9bc826de58d848f123553bc95 Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 22:00:54 +0200 Subject: [PATCH 15/17] Fixes in response to a fresh round of CodeRabbit comments --- CHANGELOG.md | 1 + src/Segwit.mo | 2 +- src/bitcoin/Wif.mo | 2 +- test/base58Check.test.mo | 16 ++++------------ test/bitcoin/p2pkh.test.mo | 9 ++++----- test/bitcoin/wif.test.mo | 29 +++++++++++++++++++++++++++++ 6 files changed, 40 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2335a4..5e76e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - P2pkh.makeScript() - Address.scriptPubKey() - Bitcoin.buildTransaction() + - Wif.decode() ## [0.2.0] - 2026-04-22 diff --git a/src/Segwit.mo b/src/Segwit.mo index d3e4da5..b48c321 100644 --- a/src/Segwit.mo +++ b/src/Segwit.mo @@ -125,7 +125,7 @@ module { /// - `"Encoding does not match witness version."` — v0 with Bech32m /// or v1+ with Bech32, /// - any error from the bit-group conversion (e.g. invalid padding). - // Convert a segwit address into a numan-readable part (HRP) and a Witness Program. + // Convert a segwit address into a human-readable part (HRP) and a Witness Program. // Decodes using Bech32. public func decode(address : Text) : Result<(Text, WitnessProgram), Text> { let (encoding, decodedHrp, data) = switch (Bech32.decode(address)) { diff --git a/src/bitcoin/Wif.mo b/src/bitcoin/Wif.mo index 88ec5d7..fb664e1 100644 --- a/src/bitcoin/Wif.mo +++ b/src/bitcoin/Wif.mo @@ -67,7 +67,7 @@ module { public func decode(key : WifPrivateKey) : Result { if (not Base58.isBase58Alphabet(key)) { - return #err("Could not base58 decode address."); + return #err("Could not base58 decode key."); }; let decoded : Iter = switch (Base58Check.decode(key)) { diff --git a/test/base58Check.test.mo b/test/base58Check.test.mo index a81c068..df5a56a 100644 --- a/test/base58Check.test.mo +++ b/test/base58Check.test.mo @@ -6,6 +6,10 @@ import Base58Check "../src/Base58Check"; let testData : [(?[Nat8], Text)] = [ ( + // Boundary case: input Base58-decodes to exactly 4 bytes (the + // checksum alone) and the payload is empty. This is the smallest + // valid Base58Check string. "3QJmnh" Base58-decodes to the 4-byte + // double-SHA256 prefix of the empty byte string. ?[], "3QJmnh", ), @@ -197,15 +201,3 @@ test( }; }, ); - -// Boundary case: input Base58-decodes to exactly 4 bytes (the checksum -// alone) and the payload is empty. This is the smallest valid Base58Check -// string. "3QJmnh" Base58-decodes to the 4-byte double-SHA256 prefix of -// the empty byte string. -test( - "decode empty payload (checksum-only input)", - func() { - let actual = Base58Check.decode("3QJmnh"); - assert (actual == ?([] : [Nat8])); - }, -); diff --git a/test/bitcoin/p2pkh.test.mo b/test/bitcoin/p2pkh.test.mo index b0f5b8f..8294cbd 100644 --- a/test/bitcoin/p2pkh.test.mo +++ b/test/bitcoin/p2pkh.test.mo @@ -105,11 +105,10 @@ runTest({ vectors = makeScriptTestCases; }); -// Per the doc string of `P2pkh.decodeAddress`, an address with an invalid -// Base58 alphabet character (e.g. `0`, `O`, `I`, `l`) should result in -// `#err("Could not base58 decode address.")`. With the current -// implementation this traps instead, because `Base58.decode` traps on -// non-alphabet characters and `Base58Check.decode` does not catch it. +// `P2pkh.decodeAddress` performs an `isBase58Alphabet` pre-check on the +// input before delegating to `Base58Check.decode`, so an address that +// contains a character outside the Base58 alphabet (e.g. `0`, `O`, `I`, +// `l`) is rejected with `#err("Could not base58 decode address.")`. type InvalidAlphabetTestCase = { address : Text; }; diff --git a/test/bitcoin/wif.test.mo b/test/bitcoin/wif.test.mo index 11a1768..ccdc2e1 100644 --- a/test/bitcoin/wif.test.mo +++ b/test/bitcoin/wif.test.mo @@ -90,6 +90,18 @@ let invalidWifTestCases : [InvalidWifTestCase] = [ // Invalid length. wif = "38uMpGARR2BJy5p4dNFKYg9UsWNoBtkpbdrXDjmfvz8krCtw3T1W92ZDSR"; }, +]; + +// Vectors that exercise the secp256k1 private-scalar guard +// (`privateKey == 0` or `privateKey >= Curves.secp256k1.r`). All of +// these decode to a syntactically valid WIF whose 32-byte payload is +// either zero or `>= r`, so `Wif.decode` must reject them with the exact +// error message "Invalid private scalar." +type InvalidScalarWifTestCase = { + wif : Text; +}; + +let invalidScalarWifTestCases : [InvalidScalarWifTestCase] = [ { // Zero scalar (mainnet, compressed). wif = "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73Nd2Mcv1"; @@ -135,6 +147,17 @@ func testInvalidWifDecode(tcase : InvalidWifTestCase) { }; }; +func testInvalidScalarWifDecode(tcase : InvalidScalarWifTestCase) { + switch (Wif.decode(tcase.wif)) { + case (#err(msg)) { + assert (msg == "Invalid private scalar."); + }; + case (#ok(_privateKey)) { + assert (false); + }; + }; +}; + let runTest = TestUtils.runTestWithDefaults; runTest({ @@ -148,3 +171,9 @@ runTest({ fn = testInvalidWifDecode; vectors = invalidWifTestCases; }); + +runTest({ + title = "Decode WIFs with out-of-range private scalar"; + fn = testInvalidScalarWifDecode; + vectors = invalidScalarWifTestCases; +}); From 6ceba99ef717b1e4a03eff951a2d18b3531e4d35 Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 22:04:18 +0200 Subject: [PATCH 16/17] Run prettier formatter --- test/bitcoin/p2trKeyPathSigHash.test.mo | 16 ++++++++++++---- test/bitcoin/p2trScriptPathSigHash.test.mo | 10 ++++++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/test/bitcoin/p2trKeyPathSigHash.test.mo b/test/bitcoin/p2trKeyPathSigHash.test.mo index 4229402..339d8dc 100644 --- a/test/bitcoin/p2trKeyPathSigHash.test.mo +++ b/test/bitcoin/p2trKeyPathSigHash.test.mo @@ -38,10 +38,14 @@ test( ); let hash0 = txLocktime0.createTaprootKeySpendSignatureHash( - testCase.amounts(), testCase.ownScript(), 0, + testCase.amounts(), + testCase.ownScript(), + 0, ); let hash42 = txLocktime42.createTaprootKeySpendSignatureHash( - testCase.amounts(), testCase.ownScript(), 0, + testCase.amounts(), + testCase.ownScript(), + 0, ); expect.blob(Blob.fromArray(hash0)).notEqual(Blob.fromArray(hash42)); @@ -62,10 +66,14 @@ test( ); let hash2 = txVersion2.createTaprootKeySpendSignatureHash( - testCase.amounts(), testCase.ownScript(), 0, + testCase.amounts(), + testCase.ownScript(), + 0, ); let hash1 = txVersion1.createTaprootKeySpendSignatureHash( - testCase.amounts(), testCase.ownScript(), 0, + testCase.amounts(), + testCase.ownScript(), + 0, ); expect.blob(Blob.fromArray(hash2)).notEqual(Blob.fromArray(hash1)); diff --git a/test/bitcoin/p2trScriptPathSigHash.test.mo b/test/bitcoin/p2trScriptPathSigHash.test.mo index c565413..0115d45 100644 --- a/test/bitcoin/p2trScriptPathSigHash.test.mo +++ b/test/bitcoin/p2trScriptPathSigHash.test.mo @@ -40,10 +40,16 @@ test( let leafHash = P2tr.leafHash(testCase.leafScript()); let hash0 = txLocktime0.createTaprootScriptSpendSignatureHash( - testCase.amounts(), testCase.ownScript(), 0, leafHash, + testCase.amounts(), + testCase.ownScript(), + 0, + leafHash, ); let hash42 = txLocktime42.createTaprootScriptSpendSignatureHash( - testCase.amounts(), testCase.ownScript(), 0, leafHash, + testCase.amounts(), + testCase.ownScript(), + 0, + leafHash, ); expect.blob(Blob.fromArray(hash0)).notEqual(Blob.fromArray(hash42)); From cc1475448986e3d6f6ccb6481baf67520a35ec25 Mon Sep 17 00:00:00 2001 From: Timo Hanke Date: Fri, 24 Apr 2026 22:07:48 +0200 Subject: [PATCH 17/17] Bump core dependency --- mops.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mops.toml b/mops.toml index 9997099..9727ac1 100644 --- a/mops.toml +++ b/mops.toml @@ -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]