From a62ee7763befefa392d840c27888134e32fccfde Mon Sep 17 00:00:00 2001 From: javiercervilla Date: Tue, 15 Apr 2025 14:01:32 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(P2wpkh=20+=20P2wsh):[+]=C2=A0Add=20mod?= =?UTF-8?q?ules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bitcoin/Address.mo | 46 ++++++++- src/bitcoin/P2wpkh.mo | 114 ++++++++++++++++++++++ src/bitcoin/P2wsh.mo | 163 +++++++++++++++++++++++++++++++ src/bitcoin/Transaction.mo | 193 +++++++++++++++++++++++++++++++++++-- src/bitcoin/Types.mo | 11 +++ 5 files changed, 516 insertions(+), 11 deletions(-) create mode 100644 src/bitcoin/P2wpkh.mo create mode 100644 src/bitcoin/P2wsh.mo diff --git a/src/bitcoin/Address.mo b/src/bitcoin/Address.mo index b5ecdcb..62f21ef 100644 --- a/src/bitcoin/Address.mo +++ b/src/bitcoin/Address.mo @@ -1,15 +1,41 @@ import P2pkh "./P2pkh"; +import P2WPKH "./P2wpkh"; +import P2WSH "./P2wsh"; import P2tr "./P2tr"; import Script "./Script"; import Segwit "../Segwit"; import Types "./Types"; import Result "mo:base/Result"; +import Nat8 "mo:base/Nat8"; module { public func addressFromText(address : Text) : Result.Result { switch (Segwit.decode(address)) { - case (#ok _) { - return #ok(#p2tr_key(address)); + case (#ok((_hrp, witnessProgram))) { + // Successfully decoded a Bech32/Bech32m address, now check version/size + if (witnessProgram.version == 0) { + if (witnessProgram.program.size() == 20) { + // Version 0, 20-byte program -> P2WPKH + return #ok(#p2wpkh(address)); + } else if (witnessProgram.program.size() == 32) { + // Version 0, 32-byte program -> P2WSH (Add #p2wsh variant to Types.mo first) + return #ok(#p2wsh(address)); // Enable if #p2wsh is added + } else { + return #err("Invalid program size for witness version 0"); + }; + } else if (witnessProgram.version == 1) { + if (witnessProgram.program.size() == 32) { + // Version 1, 32-byte program -> P2TR (Taproot) + // For now, assume key path spend as default when parsing address text + return #ok(#p2tr_key(address)); + // TODO: Decide how to differentiate between P2TR key/script from address text alone if needed + } else { + return #err("Invalid program size for witness version 1"); + }; + } else { + // Other witness versions (>= 2) are currently unassigned by BIPs + return #err("Unsupported witness version: " # Nat8.toText(witnessProgram.version)); + }; }; case (_) {}; }; @@ -28,9 +54,15 @@ module { public func scriptPubKey( address : Types.Address ) : Result.Result { - return switch (address) { + switch (address) { case (#p2pkh p2pkhAddr) { - return P2pkh.makeScript(p2pkhAddr); + P2pkh.makeScript(p2pkhAddr); + }; + case (#p2wpkh p2wpkhAddr) { + P2WPKH.makeScript(p2wpkhAddr); + }; + case (#p2wsh p2wshAddr) { + P2WSH.makeScript(p2wshAddr); }; case (#p2tr_key p2trKeyAddr) { P2tr.makeScriptFromP2trKeyAddress(p2trKeyAddr); @@ -50,6 +82,12 @@ module { case (#p2pkh address1, #p2pkh address2) { address1 == address2; }; + case (#p2wpkh addr1, #p2wpkh addr2) { + addr1 == addr2; + }; + case (#p2wsh addr1, #p2wsh addr2) { + addr1 == addr2; + }; case (#p2tr_key address1, #p2tr_key address2) { address1 == address2; }; diff --git a/src/bitcoin/P2wpkh.mo b/src/bitcoin/P2wpkh.mo new file mode 100644 index 0000000..8e08762 --- /dev/null +++ b/src/bitcoin/P2wpkh.mo @@ -0,0 +1,114 @@ +import Types "Types"; +import Script "./Script"; +import Segwit "../Segwit"; +import EcdsaTypes "../ecdsa/Types"; +import Result "mo:base/Result"; +import Nat8 "mo:base/Nat8"; +import Nat "mo:base/Nat"; +import Hash "../Hash"; + +module { + public type DecodedP2wpkhAddress = { + hrp : Text; + publicKeyHash : [Nat8]; + }; + + /// Creates the scriptPubKey for a P2WPKH address (v0). + /// The resulting script is: OP_0 PUSH_20 <20_byte_pubkey_hash> + /// + /// # Parameters: + /// - `address`: The P2WPKH address in Bech32 format (e.g., "bc1q...") + /// + /// # Returns: + /// `Result.Result` containing the Script (`#ok`) or an error message (`#err`). + public func makeScript(address : Types.P2WPkhAddress) : Result.Result { + switch (Segwit.decode(address)) { + case (#ok((_hrp, witnessProgram))) { + if (witnessProgram.version != 0) { + return #err("P2WPKH.makeScript: Invalid witness version for P2WPKH (expected 0, got " # Nat8.toText(witnessProgram.version) # ")"); + }; + if (witnessProgram.program.size() != 20) { + return #err("P2WPKH.makeScript: Invalid program size for P2WPKH (expected 20, got " # Nat.toText(witnessProgram.program.size()) # ")"); + }; + + let script : Script.Script = [ + #opcode(#OP_0), + #data(witnessProgram.program), + ]; + #ok(script); + }; + case (#err e) { + #err("Internal error in P2WPKH.makeScript: Failed to re-decode valid P2WPKH address '" # address # "': " # e); + }; + }; + }; + + func getHrp(network : Types.Network) : Text { + switch (network) { + case (#Mainnet) { "bc" }; + case (#Testnet) { "tb" }; + case (#Regtest) { "bcrt" }; // Common HRP for Regtest Bech32 + }; + }; + + /// Derives a P2WPKH (Bech32) address from an SEC1 public key. + /// Requires the public key to be in compressed format (33 bytes). + /// + /// # Parameters: + /// - `network`: The Bitcoin network (Mainnet, Testnet, Regtest). + /// - `sec1PublicKey`: The public key in SEC1 format (pair: bytes, curve). Must be compressed. + /// + /// # Returns: + /// `Result.Result` containing the Bech32 address (`#ok`) or an error message (`#err`). + public func deriveAddress( + network : Types.Network, + sec1PublicKey : EcdsaTypes.Sec1PublicKey, + ) : Result.Result { + let (pkBytes, _curve) = sec1PublicKey; + + // P2WPKH REQUIRES a compressed public key + // Assuming pkBytes.size() == 33 for compressed + // (The library might have a helper function like PublicKey.isCompressed(pkBytes)) + if (pkBytes.size() != 33) { + // Optional: We could try to compress it if it isn't, but requiring it is safer. + return #err("P2WPKH requires a compressed public key (33 bytes)"); + }; + + // 1. Calculate HASH160(pubkey) + let pubKeyHash : [Nat8] = Hash.hash160(pkBytes); + if (pubKeyHash.size() != 20) { + return #err("Internal error: HASH160 result is not 20 bytes"); + }; + + let hrp = getHrp(network); + + let witnessProgram : Segwit.WitnessProgram = { + version = 0; + program = pubKeyHash; + }; + + return Segwit.encode(hrp, witnessProgram); + }; + + public func decodeAddress(address : Types.P2WPkhAddress) : Result.Result { + switch (Segwit.decode(address)) { + case (#ok((hrp, witnessProgram))) { + // P2WPKH specific validations + if (witnessProgram.version != 0) { + return #err("P2WPKH.decodeAddress: Invalid witness version (expected 0, got " # Nat8.toText(witnessProgram.version) # ")"); + }; + if (witnessProgram.program.size() != 20) { + return #err("P2WPKH.decodeAddress: Invalid program size (expected 20, got " # Nat.toText(witnessProgram.program.size()) # ")"); + }; + // Validate HRP if necessary (e.g., hrp == getHrp(#Mainnet) or hrp == getHrp(#Testnet) ...) + + // Return the specific structure + #ok({ hrp = hrp; publicKeyHash = witnessProgram.program }); + }; + case (#err e) { + #err("P2WPKH.decodeAddress: Failed to decode Bech32 address: " # e); + }; + }; + }; + +}; \ No newline at end of file diff --git a/src/bitcoin/P2wsh.mo b/src/bitcoin/P2wsh.mo new file mode 100644 index 0000000..3178b76 --- /dev/null +++ b/src/bitcoin/P2wsh.mo @@ -0,0 +1,163 @@ +import Types "Types"; +import Script "./Script"; +import Segwit "../Segwit"; +import Common "../Common"; +import Result "mo:base/Result"; +import Nat8 "mo:base/Nat8"; +import Nat "mo:base/Nat"; +import Debug "mo:base/Debug"; +import Buffer "mo:base/Buffer"; +import Array "mo:base/Array"; +import Nat16 "mo:base/Nat16"; +import Nat32 "mo:base/Nat32"; +import Blob "mo:base/Blob"; +import Sha256 "mo:sha2/Sha256"; + +module { + + let opPushData1Code : Nat8 = 0x4c; + let opPushData1Threshold : Nat = 76; + let maxNat8 : Nat = 0xff; + let maxNat16 : Nat = 0xffff; + + public type DecodedP2wshAddress = { + hrp : Text; + scriptHash : [Nat8]; + }; + + func encodeOpcode(opcode : Script.Opcode) : Nat8 { + return switch (opcode) { + case (#OP_0) { 0x00 }; // for makeScript + case (#OP_PUSHDATA1) { opPushData1Code }; // for raw serialization + case (#OP_PUSHDATA2) { 0x4d }; // for raw serialization + case (#OP_PUSHDATA4) { 0x4e }; // for raw serialization + case _ { + Debug.trap("P2WSH internal: Unsupported opcode in encodeOpcode local replica"); + }; + }; + }; + + func serializeScriptRaw(script : Script.Script) : [Nat8] { + let buf = Buffer.Buffer(script.size()); + + for (instruction in script.vals()) { + switch (instruction) { + case (#opcode(opcode)) { + buf.add(encodeOpcode(opcode)); + }; + case (#data data) { + let dataSize = data.size(); + if (dataSize < opPushData1Threshold) { + buf.add(Nat8.fromNat(dataSize)); + } else if (dataSize <= maxNat8) { + buf.add(encodeOpcode(#OP_PUSHDATA1)); + buf.add(Nat8.fromNat(dataSize)); + } else if (dataSize <= maxNat16) { + buf.add(encodeOpcode(#OP_PUSHDATA2)); + let sizeData = Array.init(2, 0); + Common.writeLE16(sizeData, 0, Nat16.fromNat(dataSize)); + for (byte in sizeData.vals()) { buf.add(byte) }; + } else { + buf.add(encodeOpcode(#OP_PUSHDATA4)); + let sizeData = Array.init(4, 0); + Common.writeLE32(sizeData, 0, Nat32.fromNat(dataSize)); + for (byte in sizeData.vals()) { buf.add(byte) }; + }; + for (byte in data.vals()) { + buf.add(byte); + }; + }; + }; + }; + return Buffer.toArray(buf); + }; + + /// Creates the scriptPubKey for a P2WSH address (v0). + /// The resulting script is: OP_0 PUSH_32 <32_byte_script_hash> + /// + /// # Parameters: + /// - `address`: The P2WSH address in Bech32 format (e.g., "bc1q...") + /// + /// # Returns: + /// `Result.Result` containing the Script (`#ok`) or an error message (`#err`). + public func makeScript(address : Types.P2WShAddress) : Result.Result { + switch (Segwit.decode(address)) { + case (#ok((_hrp, witnessProgram))) { + if (witnessProgram.version != 0) { + return #err("P2WSH.makeScript: Invalid witness version for P2WSH (expected 0, got " # Nat8.toText(witnessProgram.version) # ")"); + }; + if (witnessProgram.program.size() != 32) { + return #err("P2WSH.makeScript: Invalid program size for P2WSH (expected 32, got " # Nat.toText(witnessProgram.program.size()) # ")"); + }; + + let script : Script.Script = [ + #opcode(#OP_0), + #data(witnessProgram.program), + ]; + #ok(script); + }; + case (#err e) { + #err("Internal error in P2WSH.makeScript: Failed to re-decode valid P2WSH address '" # address # "': " # e); + }; + }; + }; + + func getHrp(network : Types.Network) : Text { + switch (network) { + case (#Mainnet) { "bc" }; + case (#Testnet) { "tb" }; + case (#Regtest) { "bcrt" }; + }; + }; + + /// Derivate a P2WSH address (Bech32) from a witness script. + /// + /// # Parameters: + /// - `network`: Bitcoin network (Mainnet, Testnet, Regtest). + /// - `witnessScript`: the script (as `Script.Script`) wich hash SHA256 will be used. + /// + /// # Returns: + /// `Result.Result` contains the Bech32 address (`#ok`) or an error message (`#err`). + public func deriveAddress( + network : Types.Network, + witnessScript : Script.Script + ) : Result.Result { + + let rawScriptBytes = serializeScriptRaw(witnessScript); + + let scriptHashBlob = Sha256.fromArray(#sha256, rawScriptBytes); + let scriptHash = Blob.toArray(scriptHashBlob); + + if (scriptHash.size() != 32) { + return #err("Internal error: SHA256 result is not 32 bytes"); + }; + + let hrp = getHrp(network); + + let witnessProgram : Segwit.WitnessProgram = { + version = 0; + program = scriptHash; + }; + + return Segwit.encode(hrp, witnessProgram); + }; + + public func decodeAddress(address : Types.P2WShAddress) : Result.Result { + switch (Segwit.decode(address)) { + case (#ok((hrp, witnessProgram))) { + if (witnessProgram.version != 0) { + return #err("P2WSH.decodeAddress: Invalid witness version (expected 0, got " # Nat8.toText(witnessProgram.version) # ")"); + }; + if (witnessProgram.program.size() != 32) { + return #err("P2WSH.decodeAddress: Invalid program size (expected 32, got " # Nat.toText(witnessProgram.program.size()) # ")"); + }; + + #ok({ hrp = hrp; scriptHash = witnessProgram.program }); + }; + case (#err e) { + #err("P2WSH.decodeAddress: Failed to decode Bech32 address: " # e); + }; + }; + }; + +}; diff --git a/src/bitcoin/Transaction.mo b/src/bitcoin/Transaction.mo index baaedef..086d515 100644 --- a/src/bitcoin/Transaction.mo +++ b/src/bitcoin/Transaction.mo @@ -13,7 +13,7 @@ import ByteUtils "../ByteUtils"; import Types "./Types"; import TxInput "./TxInput"; import TxOutput "./TxOutput"; -import Witness "Witness"; +import Witness "./Witness"; import Sha256 "mo:sha2/Sha256"; module { @@ -104,7 +104,7 @@ module { }; }; - // build witnesses if necessary + // Build witnesses if necessary var witnesses = Array.init(txInSize, []); if (has_witness) { for (i in Iter.range(0, txInSize - 1)) { @@ -142,13 +142,14 @@ module { // Representation of a Bitcoin transaction. public class Transaction( - version : Nat32, + _version : Nat32, _txIns : [TxInput.TxInput], _txOuts : [TxOutput.TxOutput], _witnesses : [var Witness.Witness], - locktime : Nat32, + _locktime : Nat32, ) { - + public let version : Nat32 = _version; + public let locktime : Nat32 = _locktime; public let txInputs : [TxInput.TxInput] = _txIns; public let txOutputs : [TxOutput.TxOutput] = _txOuts; public let witnesses : [var Witness.Witness] = _witnesses; @@ -286,7 +287,7 @@ module { let scriptpubkeys = Array.init<[Nat8]>(txInputs.size(), Script.toBytes(scriptPubKey)); let sha_scriptpubkeys : [Nat8] = Blob.toArray(Sha256.fromArray(#sha256, Array.flatten(Array.freeze(scriptpubkeys)))); - // ignote the nSequence flag + // Ignore the nSequence flag // this is inlined generation of the 0xFFFFFFFF flag for each input // let sequences = Array.freeze(Array.init(txInputs.size() * 4, 0xFF)); @@ -349,6 +350,183 @@ module { return Hash.taggedHash(data, "TapSighash"); }; + // --- BIP 143 Helper Functions --- + + // Serialize an OutPoint (TxId LE + Vout LE) - 36 bytes + // Made public in case it's useful externally, otherwise it can be a private `func` + public func serializeOutPoint(outpoint : Types.OutPoint) : [Nat8] { + // vout is Nat32, needs 4 bytes LE + let voutBytes = Array.init(4, 0); + Common.writeLE32(voutBytes, 0, outpoint.vout); + // txid is Blob (which is [Nat8]), assume it's already in the correct order (usually LE internally) + return Array.append(Blob.toArray(outpoint.txid), Array.freeze(voutBytes)); + }; + + // Calculate hashPrevouts (DoubleSHA256 of the concatenation of all serialized OutPoints) + // Made public in case it's useful externally, otherwise it can be a private `func` + public func calculateHashPrevouts(self : Transaction) : [Nat8] { + // Buffer to store the serialized bytes of each outpoint + let buffer = Buffer.Buffer(self.txInputs.size() * 36); // Initial size estimate + for (input in self.txInputs.vals()) { + // Add the bytes of the serialized outpoint to the buffer + let serialized = serializeOutPoint(input.prevOutput); + for (byte in serialized.vals()) { buffer.add(byte) }; + }; + // Calculate the double SHA256 hash of all concatenated bytes in the buffer + return Hash.doubleSHA256(Buffer.toArray(buffer)); + }; + + // Calculate hashSequence (DoubleSHA256 of the concatenation of all serialized sequences) + // Made public in case it's useful externally, otherwise it can be a private `func` + public func calculateHashSequence(self : Transaction) : [Nat8] { + // Buffer to store the serialized bytes of each sequence + let buffer = Buffer.Buffer(self.txInputs.size() * 4); // Each sequence is 4 bytes + for (input in self.txInputs.vals()) { + // Serialize the sequence (Nat32) to 4 bytes LE + let sequenceBytes = Array.init(4, 0); + Common.writeLE32(sequenceBytes, 0, input.sequence); + // Add the bytes to the buffer + for (byte in sequenceBytes.vals()) { buffer.add(byte) }; + }; + // Calculate the double SHA256 hash of all concatenated bytes in the buffer + return Hash.doubleSHA256(Buffer.toArray(buffer)); + }; + + // Calculate hashOutputs (DoubleSHA256 of the concatenation of all serialized Outputs) + // Made public in case it's useful externally, otherwise it can be a private `func` + public func calculateHashOutputs(self : Transaction) : [Nat8] { + // Buffer to store the serialized bytes of each output + let buffer = Buffer.Buffer(self.txOutputs.size() * 34); // Estimate (8 value + 1 varint + 25 P2PKH script approx) + for (output in self.txOutputs.vals()) { + // Assume TxOutput.toBytes() serializes correctly (value LE 8 bytes + scriptPubKey VarInt + script) + let outputBytes = TxOutput.toBytes(output); + // Add the bytes to the buffer + for (byte in outputBytes.vals()) { buffer.add(byte) }; + }; + // Calculate the double SHA256 hash of all concatenated bytes in the buffer + return Hash.doubleSHA256(Buffer.toArray(buffer)); + }; + + /// Create the BIP143 signature hash for a P2WPKH (Pay-to-Witness-Public-Key-Hash) input. + /// NOTE: This initial implementation ONLY supports SIGHASH_ALL. + /// Other sighash types (NONE, SINGLE, ANYONECANPAY) will require additional logic. + /// + /// # Parameters: + /// - `self`: The current transaction. + /// - `txInputIndex`: The 0-based index of the input being signed. + /// - `scriptCode`: The specific scriptCode for P2WPKH. Should be `0x19` (VarInt 25) followed by `OP_DUP OP_HASH160 <20-byte-pubkey-hash> OP_EQUALVERIFY OP_CHECKSIG`. + /// - `value`: The value (in satoshis) of the UTXO this input is spending. + /// - `sigHashType`: The signature hash type (e.g., `BitcoinTypes.SIGHASH_ALL`). + /// + /// # Returns: + /// `Result.Result<[Nat8], Text>` containing the 32-byte hash (`#ok`) or an error message (`#err`). + public func createP2wpkhSignatureHash( + self : Transaction, + txInputIndex : Nat32, + scriptCode : [Nat8], // Should be VarInt(len) + script (e.g., 0x1976a914{pkh}88ac) + value : Nat64, // Value of the spent UTXO + sigHashType : Types.SighashType, + ) : Result.Result<[Nat8], Text> { + + // --- Basic Validation --- + let inputIdxNat = Nat32.toNat(txInputIndex); + if (inputIdxNat >= self.txInputs.size()) { + return #err( + "createP2wpkhSignatureHash: txInputIndex out of bounds (" + # Nat32.toText(txInputIndex) # " >= " # Nat.toText(self.txInputs.size()) # ")" + ); + }; + + // --- SigHashType Validation and Handling (Simplified SIGHASH_ALL Version) --- + let sighash_mask = sigHashType & 0x1f; // Base mask (ignore ANYONECANPAY for now) + let anyone_can_pay = (sigHashType & Types.SIGHASH_ANYONECANPAY) != 0; + + let ZERO_HASH = Array.init(32, 0); // Null 32-byte hash + + // Variables for the hashes (could be zero depending on flags) + var hashPrevouts : [Nat8] = Array.freeze(ZERO_HASH); + var hashSequence : [Nat8] = Array.freeze(ZERO_HASH); + var hashOutputs : [Nat8] = Array.freeze(ZERO_HASH); + + // Calculate hashPrevouts + if (anyone_can_pay) { + hashPrevouts := Array.freeze(ZERO_HASH); + } else { + hashPrevouts := self.calculateHashPrevouts(self); + }; + + // Calculate hashSequence + if (anyone_can_pay or sighash_mask == Types.SIGHASH_SINGLE or sighash_mask == Types.SIGHASH_NONE) { + hashSequence := Array.freeze(ZERO_HASH); + } else { + hashSequence := self.calculateHashSequence(self); + }; + + // Calculate hashOutputs + if (sighash_mask == Types.SIGHASH_SINGLE) { + // Only hash the output at the same index, if it exists + if (inputIdxNat < self.txOutputs.size()) { + let outputBytes = TxOutput.toBytes(self.txOutputs[inputIdxNat]); + hashOutputs := Hash.doubleSHA256(outputBytes); + } else { + // Invalid index for output (more inputs than outputs), zero hash is used + hashOutputs := Array.freeze(ZERO_HASH); + }; + } else if (sighash_mask == Types.SIGHASH_NONE) { + hashOutputs := Array.freeze(ZERO_HASH); + } else { + // SIGHASH_ALL (or default) + hashOutputs := self.calculateHashOutputs(self); + }; + + // --- Get data from the specific Input --- + let input = self.txInputs[inputIdxNat]; + let outpointBytes = self.serializeOutPoint(input.prevOutput); // 36 bytes + let sequenceBytes = Array.init(4, 0); // 4 bytes LE + Common.writeLE32(sequenceBytes, 0, input.sequence); + + // --- Serialize other components --- + let versionBytes = Array.init(4, 0); // 4 bytes LE + Common.writeLE32(versionBytes, 0, self.version); + + // `scriptCode` comes already serialized (VarInt + script) + + let valueBytes = Array.init(8, 0); // 8 bytes LE + // Ensure Common.writeLE64 exists and works + // If not, implement or use Nat64.toByteDataLE() if it exists + Common.writeLE64(valueBytes, 0, value); + + let locktimeBytes = Array.init(4, 0); // 4 bytes LE + Common.writeLE32(locktimeBytes, 0, self.locktime); + + let hashtypeBytes = Array.init(4, 0); // 4 bytes LE + Common.writeLE32(hashtypeBytes, 0, sigHashType); + + // --- Concatenate Preimage according to BIP 143 --- + // Order: version|hashPrevouts|hashSequence|outpoint|scriptCode|value|nSequence|hashOutputs|nLocktime|nHashType + let preimage = [ + Array.freeze(versionBytes), // 4 + hashPrevouts, // 32 + hashSequence, // 32 + outpointBytes, // 36 + scriptCode, // variable (e.g., 26 for P2WPKH) + Array.freeze(valueBytes), // 8 + Array.freeze(sequenceBytes), // 4 + hashOutputs, // 32 + Array.freeze(locktimeBytes), // 4 + Array.freeze(hashtypeBytes) // 4 + ]; + + // --- Calculate Final Hash (Double SHA256) --- + let sighash : [Nat8] = Hash.doubleSHA256(Array.flatten(preimage)); + + // Debug.print("Calculated P2WPKH Sighash (idx " # Nat32.toText(txInputIndex) # "): " # Blob.toHex(Blob.fromArray(sighash))); + + return #ok(sighash); + }; + + // --- END: NEW BIP 143 FUNCTIONALITY --- + /// Serialize transaction to bytes with layout: /// `| version | witness flags if it is present | len(txIns) | txIns | len(txOuts) | txOuts | witnesses | locktime |` public func toBytes() : [Nat8] { @@ -620,4 +798,5 @@ module { Array.freeze(output); }; }; -}; + +}; \ No newline at end of file diff --git a/src/bitcoin/Types.mo b/src/bitcoin/Types.mo index 90d0c3a..2541334 100644 --- a/src/bitcoin/Types.mo +++ b/src/bitcoin/Types.mo @@ -35,12 +35,23 @@ module { }; public type P2PkhAddress = Text; + public type P2WPkhAddress = Text; + public type P2WShAddress = Text; public type P2trKeyAddress = Text; public type P2trScriptAddress = Text; public type Address = { #p2pkh : P2PkhAddress; + #p2wpkh : P2WPkhAddress; + #p2wsh : P2WShAddress; #p2tr_key : P2trKeyAddress; #p2tr_script : P2trScriptAddress; }; + + public type BitcoinSendTransactionError = { + #MalformedTransaction : Text; + #QueueFull : Text; + #TemporarilyUnavailable : Text; + #Unknown : Text; + }; }; From f96e5ecb0cdf2aa7c12ca6a8bc41f6a523a3545f Mon Sep 17 00:00:00 2001 From: javiercervilla Date: Tue, 15 Apr 2025 14:01:59 +0200 Subject: [PATCH 2/3] feat(P2wpkhTest):[+] Add test for p2wpkh --- test/bitcoin/p2wpkh.test.mo | 184 ++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 test/bitcoin/p2wpkh.test.mo diff --git a/test/bitcoin/p2wpkh.test.mo b/test/bitcoin/p2wpkh.test.mo new file mode 100644 index 0000000..f8582ce --- /dev/null +++ b/test/bitcoin/p2wpkh.test.mo @@ -0,0 +1,184 @@ +// test/bitcoin/p2wpkh.test.mo +// @testmode wasi + +import Debug "mo:base/Debug"; +import Array "mo:base/Array"; +import Nat8 "mo:base/Nat8"; +import TestUtils "../TestUtils"; +import Curves "../../src/ec/Curves"; +import EcdsaTypes "../../src/ecdsa/Types"; +import Script "../../src/bitcoin/Script"; +import P2wpkh "../../src/bitcoin/P2wpkh"; +import Types "../../src/bitcoin/Types"; +import Hex "../Hex"; +import ByteUtils "../../src/ByteUtils"; + + + + +type AddressTestCase = { + compressedPublicKeyHex : Text; + expectedAddressMainnet : Types.P2WPkhAddress; + expectedAddressTestnet : Types.P2WPkhAddress; +}; + +type MakeScriptTestCase = { + address : Types.P2WPkhAddress; + expectedScriptHex : Text; +}; + +type DecodeAddressTestCase = { + address : Types.P2WPkhAddress; + expectedHrp : Text; + expectedHash160Hex : Text; +}; + +// Test cases obtained from: +// - https://guggero.github.io/cryptography-toolkit/#!/hd-wallet +// - https://guggero.github.io/cryptography-toolkit/#!/ecc + +let addressTestData : [AddressTestCase] = [ + { + compressedPublicKeyHex = "039f791acf20c911a766979dc25c3a2a5e86a281933029bca02f67a8ad57726fb3"; + expectedAddressMainnet = "bc1qjn90fmjwht2z07zq32lxalu9j4vrdee9fswawk"; + expectedAddressTestnet = "tb1qjn90fmjwht2z07zq32lxalu9j4vrdee9rk4w49"; + }, + { + compressedPublicKeyHex = "02fafa7ba8e2c69041f1081b58d1a8bd74a62b846756094078b5d8d71b25a2315b"; + expectedAddressMainnet = "bc1q2nwhc5jyu4y266awtw4pz8argftwv59ntvjvek"; + expectedAddressTestnet = "tb1q2nwhc5jyu4y266awtw4pz8argftwv59np2flz9"; + }, +]; + +let makeScriptTestCases : [MakeScriptTestCase] = [ + { + address = "bc1qjn90fmjwht2z07zq32lxalu9j4vrdee9fswawk"; + // script = OP_0 PUSH20 + expectedScriptHex = "001494caf4ee4ebad427f8408abe6eff85955836e725"; + }, + { + address = "tb1qjn90fmjwht2z07zq32lxalu9j4vrdee9rk4w49"; + expectedScriptHex = "001494caf4ee4ebad427f8408abe6eff85955836e725"; + }, + { + address = "bc1q2nwhc5jyu4y266awtw4pz8argftwv59ntvjvek"; + expectedScriptHex = "001454dd7c5244e548ad6bae5baa111fa34256e650b3"; + }, + { + address = "tb1q2nwhc5jyu4y266awtw4pz8argftwv59np2flz9"; + expectedScriptHex = "001454dd7c5244e548ad6bae5baa111fa34256e650b3"; + }, +]; + + +let decodeAddressTestCases : [DecodeAddressTestCase] = [ + { + address = "bc1qjn90fmjwht2z07zq32lxalu9j4vrdee9fswawk"; + expectedHrp = "bc"; + expectedHash160Hex = "94caf4ee4ebad427f8408abe6eff85955836e725"; + }, + { + address = "tb1qjn90fmjwht2z07zq32lxalu9j4vrdee9rk4w49"; + expectedHrp = "tb"; + expectedHash160Hex = "94caf4ee4ebad427f8408abe6eff85955836e725"; + }, + { + address = "tb1q2nwhc5jyu4y266awtw4pz8argftwv59np2flz9"; + expectedHrp = "tb"; + expectedHash160Hex = "54dd7c5244e548ad6bae5baa111fa34256e650b3"; + }, + { + address = "bc1q2nwhc5jyu4y266awtw4pz8argftwv59ntvjvek"; + expectedHrp = "bc"; + expectedHash160Hex = "54dd7c5244e548ad6bae5baa111fa34256e650b3"; + }, +]; + + +func testP2wpkhDeriveAddress(testCase : AddressTestCase) { + let pkBytes = switch (Hex.decode(testCase.compressedPublicKeyHex)) { + case (#ok bytes) bytes; + case (#err e) Debug.trap("Bad hex key: " # e); + }; + let sec1Key : EcdsaTypes.Sec1PublicKey = (pkBytes, Curves.secp256k1); + + switch (P2wpkh.deriveAddress(#Mainnet, sec1Key)) { + case (#ok addr) assert (testCase.expectedAddressMainnet == addr); + case (#err msg) Debug.trap("Mainnet derivation failed: " # msg); + }; + + switch (P2wpkh.deriveAddress(#Testnet, sec1Key)) { + case (#ok addr) assert (testCase.expectedAddressTestnet == addr); + case (#err msg) Debug.trap("Testnet derivation failed: " # msg); + }; +}; + +func testP2wpkhDecodeAddress(testCase : DecodeAddressTestCase) { + let expectedHash = switch (Hex.decode(testCase.expectedHash160Hex)) { + case (#ok bytes) bytes; + case (#err e) Debug.trap("Bad hex hash: " # e); + }; + + switch (P2wpkh.decodeAddress(testCase.address)) { + case (#ok decoded) { + assert (testCase.expectedHrp == decoded.hrp); + assert ( + Array.equal( + expectedHash, + decoded.publicKeyHash, + Nat8.equal, + ) == true + ); + }; + case (#err msg) { + Debug.trap("Decode failed: " # msg); + }; + }; +}; + +func testP2wpkhMakeScript(testCase : MakeScriptTestCase) { + let expectedScriptContentBytes = switch (Hex.decode(testCase.expectedScriptHex)) { + case (#ok bytes) bytes; + case (#err e) Debug.trap("Bad hex script: " # e); + }; + + let prefixBytes = ByteUtils.writeVarint(expectedScriptContentBytes.size()); + + let finalExpectedBytes = Array.append(prefixBytes, expectedScriptContentBytes); + + switch (P2wpkh.makeScript(testCase.address)) { + case (#ok script) { + let actualBytes = Script.toBytes(script); + assert ( + Array.equal( + finalExpectedBytes, + actualBytes, + Nat8.equal, + ) == true + ); + }; + case (#err msg) { + Debug.trap("MakeScript failed: " # msg); + }; + }; +}; + +let runTest = TestUtils.runTestWithDefaults; + +runTest({ + title = "P2WPKH address derivation"; + fn = testP2wpkhDeriveAddress; + vectors = addressTestData; +}); + +runTest({ + title = "Decode P2WPKH address"; + fn = testP2wpkhDecodeAddress; + vectors = decodeAddressTestCases; +}); + +runTest({ + title = "Make P2WPKH script"; + fn = testP2wpkhMakeScript; + vectors = makeScriptTestCases; +}); From 07ac6be8f153a8a035f8fbfe146aa7d342f94e41 Mon Sep 17 00:00:00 2001 From: javiercervilla Date: Tue, 15 Apr 2025 20:01:20 +0200 Subject: [PATCH 3/3] feat(TestSigHash):[+] Add P2pwkh sighash test and test vectors (NOTE: Working only for SIGHASH_ALL) --- test/bitcoin/p2wpkhSighash.test.mo | 81 ++++++++++++++++++++++++++++ test/bitcoin/p2wpkhSighashVectors.mo | 30 +++++++++++ 2 files changed, 111 insertions(+) create mode 100644 test/bitcoin/p2wpkhSighash.test.mo create mode 100644 test/bitcoin/p2wpkhSighashVectors.mo diff --git a/test/bitcoin/p2wpkhSighash.test.mo b/test/bitcoin/p2wpkhSighash.test.mo new file mode 100644 index 0000000..a054713 --- /dev/null +++ b/test/bitcoin/p2wpkhSighash.test.mo @@ -0,0 +1,81 @@ +// test/bitcoin/p2wpkhSighash.test.mo +// @testmode wasi + +import Debug "mo:base/Debug"; +import Array "mo:base/Array"; +import Nat8 "mo:base/Nat8"; +import Nat32 "mo:base/Nat32"; +import Int32 "mo:base/Int32"; +import TestVectors "./p2wpkhSighashVectors"; +import TestUtils "../TestUtils"; +import Hex "../Hex"; +import Transaction "../../src/bitcoin/Transaction"; +import Types "../../src/bitcoin/Types"; + +type TestCase = TestVectors.P2wpkhSighashTestCase; + +let tests = TestVectors.vectors; + +func testP2wpkhSighash(tcase : TestCase) { + let hashTypeNat32 : Nat32 = Int32.toNat32(tcase.hashType); + + if (hashTypeNat32 != Types.SIGHASH_ALL) { + Debug.print("Skipping test (" # tcase.description # "): Unsupported hashType " # Int32.toText(tcase.hashType)); + return; + }; + + let txData = switch (Hex.decode(tcase.txHex)) { + case (#ok bytes) bytes; + case (#err e) Debug.trap("Bad txHex hex: " # e); + }; + let scriptCodeBytes = switch (Hex.decode(tcase.scriptCodeHex)) { + case (#ok bytes) bytes; + case (#err e) Debug.trap("Bad scriptCodeHex hex: " # e); + }; + let expectedSighashBytes = switch (Hex.decode(tcase.expectedSighashHex)) { + case (#ok bytes) bytes; + case (#err e) Debug.trap("Bad expectedSighashHex hex: " # e); + }; + + let tx = switch (Transaction.fromBytes(txData.vals())) { + case (#ok tx) { tx }; + case (#err msg) { + Debug.trap("Could not deserialize transaction data: " # msg); + }; + }; + + let actualSighashResult = tx.createP2wpkhSignatureHash( + tx, + tcase.inputIndex, + scriptCodeBytes, + tcase.amount, + hashTypeNat32 + ); + + switch (actualSighashResult) { + case (#ok actualSighashBytes) { + if (not Array.equal(expectedSighashBytes, actualSighashBytes, Nat8.equal)) { + Debug.print("--- TEST FAILED: " # tcase.description # " ---"); + Debug.print("Expected Sighash: " # debug_show(expectedSighashBytes)); + Debug.print("Actual Sighash: " # debug_show(actualSighashBytes)); + }; + assert ( + Array.equal( + expectedSighashBytes, + actualSighashBytes, + Nat8.equal + ) == true + ); + }; + case (#err msg) { + Debug.trap("createP2wpkhSignatureHash failed for (" # tcase.description # "): " # msg); + }; + }; +}; + +// Ejecutar todos los tests +TestUtils.runTestWithDefaults({ + title = "P2WPKH Sighash (BIP143)"; + fn = testP2wpkhSighash; + vectors = tests; +}); \ No newline at end of file diff --git a/test/bitcoin/p2wpkhSighashVectors.mo b/test/bitcoin/p2wpkhSighashVectors.mo new file mode 100644 index 0000000..a075e0d --- /dev/null +++ b/test/bitcoin/p2wpkhSighashVectors.mo @@ -0,0 +1,30 @@ +// test/bitcoin/p2wpkhSighashTestVectors.mo + +import Nat32 "mo:base/Nat32"; +import Nat64 "mo:base/Nat64"; +import Int32 "mo:base/Int32"; + +module { + public type P2wpkhSighashTestCase = { + txHex : Text; + scriptCodeHex : Text; + inputIndex : Nat32; + amount : Nat64; + hashType : Int32; + expectedSighashHex : Text; + description : Text; + }; + + // Fuente: BIP143 Sección "Example" (https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki#examples) + public let vectors : [P2wpkhSighashTestCase] = [ + { + description = "BIP143 Example: Native P2WPKH SIGHASH_ALL (Input 1)"; + txHex = "0100000002fff7f7881a8099afa6940d42d1e7f6362bec38171ea3edf433541db4e4ad969f0000000000eeffffffef51e1b804cc89d182d279655c3aa89e815b1b309fe287d9b2b55d57b90ec68a0100000000ffffffff02202cb206000000001976a9148280b37df378db99f66f85c95a783a76ac7a6d5988ac9093510d000000001976a9143bde42dbee7e4dbe6a21b2d50ce2f0167faa815988ac11000000"; + scriptCodeHex = "1976a9141d0f172a0ecb48aee1be1f2687d2963ae33f71a188ac"; + inputIndex = 1; + amount = 600000000; + hashType = 1; + expectedSighashHex = "c37af31116d1b27caf68aae9e3ac82f1477929014d5b917657d0eb49478cb670"; + } + ]; +};