From 68352e9e61539fe7f676aa3c10a3aeb15ced2bc1 Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko Date: Sun, 11 Aug 2024 23:01:36 +0200 Subject: [PATCH 01/30] add p2tr untweaked key spend implementation to motoko --- motoko/basic_bitcoin/README.md | 15 +- motoko/basic_bitcoin/motoko-bitcoin | 2 +- .../src/basic_bitcoin/basic_bitcoin.did | 13 +- .../src/basic_bitcoin/src/BitcoinApi.mo | 8 +- .../src/basic_bitcoin/src/BitcoinWallet.mo | 310 ++++++++++++++---- .../src/basic_bitcoin/src/EcdsaApi.mo | 2 +- .../src/basic_bitcoin/src/Main.mo | 23 +- .../src/basic_bitcoin/src/SchnorrApi.mo | 52 +++ .../src/basic_bitcoin/src/Types.mo | 44 ++- .../src/basic_bitcoin/src/Utils.mo | 8 +- 10 files changed, 384 insertions(+), 93 deletions(-) create mode 100644 motoko/basic_bitcoin/src/basic_bitcoin/src/SchnorrApi.mo diff --git a/motoko/basic_bitcoin/README.md b/motoko/basic_bitcoin/README.md index 15c882760..5e34b5a97 100644 --- a/motoko/basic_bitcoin/README.md +++ b/motoko/basic_bitcoin/README.md @@ -166,4 +166,17 @@ If you base your application on this example, we recommend you familiarize yours For example, the following aspects are particularly relevant for this app: * [Certify query responses if they are relevant for security](https://internetcomputer.org/docs/current/references/security/general-security-best-practices#certify-query-responses-if-they-are-relevant-for-security), since the app e.g. offers a method to read balances. -* [Use a decentralized governance system like SNS to make a canister have a decentralized controller](https://internetcomputer.org/docs/current/developer-docs/security/security-best-practices/overview) \ No newline at end of file +* [Use a decentralized governance system like SNS to make a canister have a decentralized controller](https://internetcomputer.org/docs/current/developer-docs/security/security-best-practices/overview) + +## Taproot transactions +In addition to P2PKH transactions, this example now also suppots P2TR +transactions, namely the so-called untweaked key path P2TR transactions, which +is the most efficient way of performing a P2TR transaction. The limitation of +this type of transactions is that it cannot be used in combination with scripts. +IMPORTANT: Note that BIP341 advises against using taproot addresses that can be +spent with an untweaked key. This precaution is to prevent attacks that can +occur when creating taproot multisigner addresses using specific multisignature +schemes. However, the Schnorr API of the internet computer does not support +Schnorr multisignatures. + +This implementation has only been tested locally with regtest. \ No newline at end of file diff --git a/motoko/basic_bitcoin/motoko-bitcoin b/motoko/basic_bitcoin/motoko-bitcoin index 10cf7f2ef..ca9f6eee3 160000 --- a/motoko/basic_bitcoin/motoko-bitcoin +++ b/motoko/basic_bitcoin/motoko-bitcoin @@ -1 +1 @@ -Subproject commit 10cf7f2efbba9bb4d077013db1b990143f193844 +Subproject commit ca9f6eee3b2f908ad35f9dbeb9d05ab8a3b1f4b2 diff --git a/motoko/basic_bitcoin/src/basic_bitcoin/basic_bitcoin.did b/motoko/basic_bitcoin/src/basic_bitcoin/basic_bitcoin.did index 7002f2579..447016383 100644 --- a/motoko/basic_bitcoin/src/basic_bitcoin/basic_bitcoin.did +++ b/motoko/basic_bitcoin/src/basic_bitcoin/basic_bitcoin.did @@ -33,15 +33,22 @@ type get_utxos_response = record { }; service : (network) -> { - "get_p2pkh_address": () -> (bitcoin_address); - "get_balance": (address: bitcoin_address) -> (satoshi); "get_utxos": (bitcoin_address) -> (get_utxos_response); "get_current_fee_percentiles": () -> (vec millisatoshi_per_vbyte); - "send": (record { + "get_p2pkh_address": () -> (bitcoin_address); + + "send_from_p2pkh_address": (record { + destination_address: bitcoin_address; + amount_in_satoshi: satoshi; + }) -> (transaction_id); + + "get_p2tr_raw_key_spend_address": () -> (bitcoin_address); + + "send_from_p2tr_raw_key_spend_address": (record { destination_address: bitcoin_address; amount_in_satoshi: satoshi; }) -> (transaction_id); diff --git a/motoko/basic_bitcoin/src/basic_bitcoin/src/BitcoinApi.mo b/motoko/basic_bitcoin/src/basic_bitcoin/src/BitcoinApi.mo index 215f6ea80..cd7e2d9db 100644 --- a/motoko/basic_bitcoin/src/basic_bitcoin/src/BitcoinApi.mo +++ b/motoko/basic_bitcoin/src/basic_bitcoin/src/BitcoinApi.mo @@ -36,7 +36,7 @@ module { /// Relies on the `bitcoin_get_balance` endpoint. /// See https://internetcomputer.org/docs/current/references/ic-interface-spec/#ic-bitcoin_get_balance public func get_balance(network : Network, address : BitcoinAddress) : async Satoshi { - ExperimentalCycles.add(GET_BALANCE_COST_CYCLES); + ExperimentalCycles.add(GET_BALANCE_COST_CYCLES); await management_canister_actor.bitcoin_get_balance({ address; network; @@ -49,7 +49,7 @@ module { /// NOTE: Relies on the `bitcoin_get_utxos` endpoint. /// See https://internetcomputer.org/docs/current/references/ic-interface-spec/#ic-bitcoin_get_utxos public func get_utxos(network : Network, address : BitcoinAddress) : async GetUtxosResponse { - ExperimentalCycles.add(GET_UTXOS_COST_CYCLES); + ExperimentalCycles.add(GET_UTXOS_COST_CYCLES); await management_canister_actor.bitcoin_get_utxos({ address; network; @@ -63,7 +63,7 @@ module { /// Relies on the `bitcoin_get_current_fee_percentiles` endpoint. /// See https://internetcomputer.org/docs/current/references/ic-interface-spec/#ic-bitcoin_get_current_fee_percentiles public func get_current_fee_percentiles(network : Network) : async [MillisatoshiPerVByte] { - ExperimentalCycles.add(GET_CURRENT_FEE_PERCENTILES_COST_CYCLES); + ExperimentalCycles.add(GET_CURRENT_FEE_PERCENTILES_COST_CYCLES); await management_canister_actor.bitcoin_get_current_fee_percentiles({ network; }) @@ -77,7 +77,7 @@ module { let transaction_fee = SEND_TRANSACTION_BASE_COST_CYCLES + transaction.size() * SEND_TRANSACTION_COST_CYCLES_PER_BYTE; - ExperimentalCycles.add(transaction_fee); + ExperimentalCycles.add(transaction_fee); await management_canister_actor.bitcoin_send_transaction({ network; transaction; diff --git a/motoko/basic_bitcoin/src/basic_bitcoin/src/BitcoinWallet.mo b/motoko/basic_bitcoin/src/basic_bitcoin/src/BitcoinWallet.mo index 3b37f2a23..a26711e7a 100644 --- a/motoko/basic_bitcoin/src/basic_bitcoin/src/BitcoinWallet.mo +++ b/motoko/basic_bitcoin/src/basic_bitcoin/src/BitcoinWallet.mo @@ -15,22 +15,26 @@ import Nat32 "mo:base/Nat32"; import Nat64 "mo:base/Nat64"; import Iter "mo:base/Iter"; import Blob "mo:base/Blob"; +import Nat "mo:base/Nat"; +import Option "mo:base/Option"; import EcdsaTypes "../../../motoko-bitcoin/src/ecdsa/Types"; import P2pkh "../../../motoko-bitcoin/src/bitcoin/P2pkh"; import Bitcoin "../../../motoko-bitcoin/src/bitcoin/Bitcoin"; import Address "../../../motoko-bitcoin/src/bitcoin/Address"; import Transaction "../../../motoko-bitcoin/src/bitcoin/Transaction"; +import TxInput "../../../motoko-bitcoin/src/bitcoin/TxInput"; import Script "../../../motoko-bitcoin/src/bitcoin/Script"; import Publickey "../../../motoko-bitcoin/src/ecdsa/Publickey"; import Der "../../../motoko-bitcoin/src/ecdsa/Der"; import Affine "../../../motoko-bitcoin/src/ec/Affine"; +import Segwit "../../../motoko-bitcoin/src/Segwit"; -import Types "Types"; -import EcdsaApi "EcdsaApi"; import BitcoinApi "BitcoinApi"; +import EcdsaApi "EcdsaApi"; +import SchnorrApi "SchnorrApi"; +import Types "Types"; import Utils "Utils"; - module { type Network = Types.Network; type BitcoinAddress = Types.BitcoinAddress; @@ -51,7 +55,15 @@ module { let public_key = await EcdsaApi.ecdsa_public_key(key_name, Array.map(derivation_path, Blob.fromArray)); // Compute the address. - public_key_to_p2pkh_address(network, Blob.toArray(public_key)) + public_key_to_p2pkh_address(network, Blob.toArray(public_key)); + }; + + public func get_p2tr_raw_key_spend_address(network : Network, key_name : Text, derivation_path : [[Nat8]]) : async BitcoinAddress { + // Fetch the public key of the given derivation path. + let sec1_public_key = await SchnorrApi.schnorr_public_key(key_name, Array.map(derivation_path, Blob.fromArray)); + assert sec1_public_key.size() == 33; + + public_key_to_p2tr_key_spend_address(network, Blob.toArray(sec1_public_key)); }; /// Sends a transaction to the network that transfers the given amount to the @@ -61,21 +73,20 @@ module { // Get fee percentiles from previous transactions to estimate our own fee. let fee_percentiles = await BitcoinApi.get_current_fee_percentiles(network); - let fee_per_vbyte : MillisatoshiPerVByte = if(fee_percentiles.size() == 0) { - // There are no fee percentiles. This case can only happen on a regtest - // network where there are no non-coinbase transactions. In this case, - // we use a default of 1000 millisatoshis/vbyte (i.e. 2 satoshi/byte) - 2000 + let fee_per_vbyte : MillisatoshiPerVByte = if (fee_percentiles.size() == 0) { + // There are no fee percentiles. This case can only happen on a regtest + // network where there are no non-coinbase transactions. In this case, + // we use a default of 1000 millisatoshis/vbyte (i.e. 2 satoshi/byte) + 2000; } else { - // Choose the 50th percentile for sending fees. - fee_percentiles[50] + // Choose the 50th percentile for sending fees. + fee_percentiles[50]; }; // Fetch our public key, P2PKH address, and UTXOs. let own_public_key = Blob.toArray(await EcdsaApi.ecdsa_public_key(key_name, Array.map(derivation_path, Blob.fromArray))); let own_address = public_key_to_p2pkh_address(network, own_public_key); - Debug.print("Fetching UTXOs..."); // Note that pagination may have to be used to get all UTXOs for the given address. // For the sake of simplicity, it is assumed here that the `utxo` field in the response // contains all UTXOs. @@ -83,35 +94,30 @@ module { // Build the transaction that sends `amount` to the destination address. let tx_bytes = await build_transaction(own_public_key, own_address, own_utxos, dst_address, amount, fee_per_vbyte); - let transaction = - Utils.get_ok(Transaction.fromBytes(Iter.fromArray(tx_bytes))); - - Debug.print("Transaction to sign: " # debug_show(tx_bytes)); + let transaction = Utils.get_ok(Transaction.fromBytes(Iter.fromArray(tx_bytes))); // Sign the transaction. let signed_transaction_bytes = await sign_transaction(own_public_key, own_address, transaction, key_name, Array.map(derivation_path, Blob.fromArray), EcdsaApi.sign_with_ecdsa); let signed_transaction = Utils.get_ok(Transaction.fromBytes(Iter.fromArray(signed_transaction_bytes))); - Debug.print("Signed transaction: " # debug_show(signed_transaction_bytes)); - - Debug.print("Sending transaction..."); + Debug.print("Sending transaction"); await BitcoinApi.send_transaction(network, signed_transaction_bytes); - Debug.print("Done"); - signed_transaction.id() + signed_transaction.id(); }; - -// Builds a transaction to send the given `amount` of satoshis to the -// destination address. -public func build_transaction( + // Builds a transaction to send the given `amount` of satoshis to the + // destination address. + public func build_transaction( own_public_key : [Nat8], own_address : BitcoinAddress, own_utxos : [Utxo], dst_address : BitcoinAddress, amount : Satoshi, fee_per_vbyte : MillisatoshiPerVByte, -) : async [Nat8] { + ) : async [Nat8] { + let dst_address_typed = Utils.get_ok_except(Address.addressFromText(dst_address), "failed to decode destination address"); + // We have a chicken-and-egg problem where we need to know the length // of the transaction in order to compute its proper fee, but we need // to know the proper fee in order to figure out the inputs needed for @@ -124,29 +130,28 @@ public func build_transaction( Debug.print("Building transaction..."); var total_fee : Nat = 0; loop { - let transaction = - Utils.get_ok_except(Bitcoin.buildTransaction(2, own_utxos, [(#p2pkh dst_address, amount)], #p2pkh own_address, Nat64.fromNat(total_fee)), "Error building transaction."); - - // Sign the transaction. In this case, we only care about the size - // of the signed transaction, so we use a mock signer here for efficiency. - let signed_transaction_bytes = await sign_transaction( - own_public_key, - own_address, - transaction, - "", // mock key name - [], // mock derivation path - mock_signer, - ); - - let signed_tx_bytes_len : Nat = signed_transaction_bytes.size(); - - if((signed_tx_bytes_len * fee_per_vbyte_nat) / 1000 == total_fee) { - Debug.print("Transaction built with fee " # debug_show(total_fee)); - return transaction.toBytes(); - } else { - total_fee := (signed_tx_bytes_len * fee_per_vbyte_nat) / 1000; - } - } + let transaction = Utils.get_ok_except(Bitcoin.buildTransaction(2, own_utxos, [(dst_address_typed, amount)], #p2pkh own_address, Nat64.fromNat(total_fee)), "Error building transaction."); + + // Sign the transaction. In this case, we only care about the size + // of the signed transaction, so we use a mock signer here for efficiency. + let signed_transaction_bytes = await sign_transaction( + own_public_key, + own_address, + transaction, + "", // mock key name + [], // mock derivation path + mock_signer, + ); + + let signed_tx_bytes_len : Nat = signed_transaction_bytes.size(); + + if ((signed_tx_bytes_len * fee_per_vbyte_nat) / 1000 == total_fee) { + Debug.print("Transaction built with fee " # debug_show (total_fee)); + return transaction.toBytes(); + } else { + total_fee := (signed_tx_bytes_len * fee_per_vbyte_nat) / 1000; + }; + }; }; type SignFun = (Text, [Blob], Blob) -> async Blob; @@ -174,27 +179,32 @@ public func build_transaction( // Obtain scriptSigs for each Tx input. for (i in Iter.range(0, transaction.txInputs.size() - 1)) { - let sighash = transaction.createSignatureHash( - scriptPubKey, Nat32.fromIntWrap(i), SIGHASH_ALL); + let sighash = transaction.createP2pkhSignatureHash( + scriptPubKey, + Nat32.fromIntWrap(i), + SIGHASH_ALL, + ); let signature_sec = await signer(key_name, derivation_path, Blob.fromArray(sighash)); let signature_der = Blob.toArray(Der.encodeSignature(signature_sec)); // Append the sighash type. let encodedSignatureWithSighashType = Array.tabulate( - signature_der.size() + 1, func (n) { + signature_der.size() + 1, + func(n) { if (n < signature_der.size()) { - signature_der[n] + signature_der[n]; } else { - Nat8.fromNat(Nat32.toNat(SIGHASH_ALL)) + Nat8.fromNat(Nat32.toNat(SIGHASH_ALL)); }; - }); + }, + ); // Create Script Sig which looks like: // ScriptSig = . let script = [ #data encodedSignatureWithSighashType, - #data own_public_key + #data own_public_key, ]; scriptSigs[i] := script; }; @@ -204,11 +214,10 @@ public func build_transaction( }; }; // Verify that our own address is P2PKH. - case (#err msg) - Debug.trap("This example supports signing p2pkh addresses only."); + case (#err msg) Debug.trap("This example supports signing p2pkh addresses only: " # msg); }; - transaction.toBytes() + transaction.toBytes(); }; // Converts a public key to a P2PKH address. @@ -216,16 +225,189 @@ public func build_transaction( let public_key = public_key_bytes_to_public_key(public_key_bytes); // Compute the P2PKH address from our public key. - P2pkh.deriveAddress(Types.network_to_network_camel_case(network), Publickey.toSec1(public_key, true)) + P2pkh.deriveAddress(Types.network_to_network_camel_case(network), Publickey.toSec1(public_key, true)); + }; + + // Converts a public key to a P2PKH address. + func public_key_to_p2tr_key_spend_address(network : Network, public_key_bytes : [Nat8]) : BitcoinAddress { + // human-readable part of the address + let hrp = switch (network) { + case (#mainnet) "bc"; + case (#testnet) "tb"; + case (#regtest) "bcrt"; + }; + + let version : Nat8 = 1; + let bip340PublicKeyBytes = Array.subArray(public_key_bytes, 1, 32); + assert bip340PublicKeyBytes.size() == 32; + let program = bip340PublicKeyBytes; + + switch (Segwit.encode(hrp, { version; program })) { + case (#ok address) address; + case (#err msg) Debug.trap("Error encoding segwit address: " # msg); + }; }; // A mock for rubber-stamping ECDSA signatures. func mock_signer(_key_name : Text, _derivation_path : [Blob], _message_hash : Blob) : async Blob { - Blob.fromArray(Array.freeze(Array.init(64, 255))) + Blob.fromArray(Array.freeze(Array.init(64, 255))); }; func public_key_bytes_to_public_key(public_key_bytes : [Nat8]) : PublicKey { - let point = Utils.unwrap(Affine.fromBytes(public_key_bytes, CURVE)); - Utils.get_ok(Publickey.decode(#point point)) + let point = Utils.unwrap(Affine.fromBytes(public_key_bytes, CURVE)); + Utils.get_ok(Publickey.decode(#point point)); + }; + + // Builds a transaction to send the given `amount` of satoshis to the + // destination address. + public func build_taproot_transaction( + own_address : BitcoinAddress, + own_utxos : [Utxo], + dst_address : BitcoinAddress, + amount : Satoshi, + fee_per_vbyte : MillisatoshiPerVByte, + ) : async [Nat8] { + let dst_address_typed = Utils.get_ok_except(Address.addressFromText(dst_address), "failed to decode destination address"); + + // We have a chicken-and-egg problem where we need to know the length + // of the transaction in order to compute its proper fee, but we need + // to know the proper fee in order to figure out the inputs needed for + // the transaction. + // + // We solve this problem iteratively. We start with a fee of zero, build + // and sign a transaction, see what its size is, and then update the fee, + // rebuild the transaction, until the fee is set to the correct amount. + let fee_per_vbyte_nat = Nat64.toNat(fee_per_vbyte); + var total_fee : Nat = 0; + + loop { + let transaction = Utils.get_ok_except(Bitcoin.buildTransaction(2, own_utxos, [(dst_address_typed, amount)], #p2tr_key own_address, Nat64.fromNat(total_fee)), "Error building transaction."); + let tx_in_outpoints = Array.map(transaction.txInputs, func(txin) { txin.prevOutput }); + + let amounts = Array.mapFilter( + own_utxos, + func(utxo) { + if (Option.isSome(Array.find(tx_in_outpoints, func(tx_in_outpoint) { tx_in_outpoint == utxo.outpoint }))) { + ?utxo.value; + } else { + null; + }; + }, + ); + + // Sign the transaction. In this case, we only care about the size + // of the signed transaction, so we use a mock signer here for efficiency. + let signed_transaction_bytes = await sign_taproot_transaction( + own_address, + transaction, + amounts, + "", // mock key name + [], // mock derivation path + mock_signer, + ); + + let signed_tx_bytes_len : Nat = signed_transaction_bytes.size(); + + if ((signed_tx_bytes_len * fee_per_vbyte_nat) / 1000 == total_fee) { + Debug.print("Transaction built with fee " # debug_show (total_fee)); + return transaction.toBytes(); + } else { + total_fee := (signed_tx_bytes_len * fee_per_vbyte_nat) / 1000; + }; + }; + }; + + // Sign a bitcoin transaction. + // + // IMPORTANT: This method is for demonstration purposes only and it only + // supports signing transactions if: + // + // 1. All the inputs are referencing outpoints that are owned by `own_address`. + // 2. `own_address` is a P2PKH address. + public func sign_taproot_transaction( + own_address : BitcoinAddress, + transaction : Transaction, + amounts : [Nat64], + key_name : Text, + derivation_path : [Blob], + signer : SignFun, + ) : async [Nat8] { + // Obtain the scriptPubKey of the source address which is also the + // scriptPubKey of the Tx output being spent. + switch (Address.scriptPubKey(#p2tr_key own_address)) { + case (#ok scriptPubKey) { + assert scriptPubKey.size() == 2; + + // Obtain a witness for each Tx input. + for (i in Iter.range(0, transaction.txInputs.size() - 1)) { + let sighash = transaction.createTaprootKeySpendSignatureHash( + amounts, + scriptPubKey, + Nat32.fromIntWrap(i), + ); + + let signature = Blob.toArray(await signer(key_name, derivation_path, Blob.fromArray(sighash))); + transaction.witnesses[i] := [signature]; + }; + }; + // Verify that our own address is P2TR key spend address. + case (#err msg) Debug.trap("This example supports signing p2tr key spend addresses only: " # msg); + }; + + transaction.toBytes(); + }; + + /// Sends a transaction to the network that transfers the given amount to the + /// given destination, where the source of the funds is the canister itself + /// at the given derivation path. + public func send_p2tr_raw_key_spend(network : Network, derivation_path : [[Nat8]], key_name : Text, dst_address : BitcoinAddress, amount : Satoshi) : async [Nat8] { + // Get fee percentiles from previous transactions to estimate our own fee. + let fee_percentiles = await BitcoinApi.get_current_fee_percentiles(network); + + let fee_per_vbyte : MillisatoshiPerVByte = if (fee_percentiles.size() == 0) { + // There are no fee percentiles. This case can only happen on a regtest + // network where there are no non-coinbase transactions. In this case, + // we use a default of 1000 millisatoshis/vbyte (i.e. 2 satoshi/byte) + 2000; + } else { + // Choose the 50th percentile for sending fees. + fee_percentiles[50]; + }; + + // Fetch our public key, P2PKH address, and UTXOs. + let own_sec1_public_key = Blob.toArray(await SchnorrApi.schnorr_public_key(key_name, Array.map(derivation_path, Blob.fromArray))); + let own_address = public_key_to_p2tr_key_spend_address(network, own_sec1_public_key); + + Debug.print("Fetching UTXOs..."); + // Note that pagination may have to be used to get all UTXOs for the given address. + // For the sake of simplicity, it is assumed here that the `utxo` field in the response + // contains all UTXOs. + let own_utxos = (await BitcoinApi.get_utxos(network, own_address)).utxos; + + // Build the transaction that sends `amount` to the destination address. + let tx_bytes = await build_taproot_transaction(own_address, own_utxos, dst_address, amount, fee_per_vbyte); + let transaction = Utils.get_ok(Transaction.fromBytes(Iter.fromArray(tx_bytes))); + + let tx_in_outpoints = Array.map(transaction.txInputs, func(txin) { txin.prevOutput }); + + let amounts = Array.mapFilter( + own_utxos, + func(utxo) { + if (Option.isSome(Array.find(tx_in_outpoints, func(tx_in_outpoint) { tx_in_outpoint == utxo.outpoint }))) { + ?utxo.value; + } else { + null; + }; + }, + ); + + // Sign the transaction. + let signed_transaction_bytes = await sign_taproot_transaction(own_address, transaction, amounts, key_name, Array.map(derivation_path, Blob.fromArray), SchnorrApi.sign_with_schnorr); + let signed_transaction = Utils.get_ok(Transaction.fromBytes(Iter.fromArray(signed_transaction_bytes))); + + Debug.print("Sending transaction..."); + await BitcoinApi.send_transaction(network, signed_transaction_bytes); + + signed_transaction.id(); }; -} +}; diff --git a/motoko/basic_bitcoin/src/basic_bitcoin/src/EcdsaApi.mo b/motoko/basic_bitcoin/src/basic_bitcoin/src/EcdsaApi.mo index 40d525edf..2569c81fd 100644 --- a/motoko/basic_bitcoin/src/basic_bitcoin/src/EcdsaApi.mo +++ b/motoko/basic_bitcoin/src/basic_bitcoin/src/EcdsaApi.mo @@ -37,7 +37,7 @@ module { }; public func sign_with_ecdsa(key_name : Text, derivation_path : [Blob], message_hash : Blob) : async Blob { - ExperimentalCycles.add(SIGN_WITH_ECDSA_COST_CYCLES); + ExperimentalCycles.add(SIGN_WITH_ECDSA_COST_CYCLES); let res = await ecdsa_canister_actor.sign_with_ecdsa({ message_hash; derivation_path; diff --git a/motoko/basic_bitcoin/src/basic_bitcoin/src/Main.mo b/motoko/basic_bitcoin/src/basic_bitcoin/src/Main.mo index 61332c4cc..0dff2743a 100644 --- a/motoko/basic_bitcoin/src/basic_bitcoin/src/Main.mo +++ b/motoko/basic_bitcoin/src/basic_bitcoin/src/Main.mo @@ -28,34 +28,41 @@ actor class BasicBitcoin(_network : Types.Network) { // For local development, we use a special test key with dfx. case (#regtest) "dfx_test_key"; // On the IC we're using a test ECDSA key. - case _ "test_key_1" + case _ "test_key_1"; }; /// Returns the balance of the given Bitcoin address. public func get_balance(address : BitcoinAddress) : async Satoshi { - await BitcoinApi.get_balance(NETWORK, address) + await BitcoinApi.get_balance(NETWORK, address); }; /// Returns the UTXOs of the given Bitcoin address. public func get_utxos(address : BitcoinAddress) : async GetUtxosResponse { - await BitcoinApi.get_utxos(NETWORK, address) + await BitcoinApi.get_utxos(NETWORK, address); }; /// Returns the 100 fee percentiles measured in millisatoshi/vbyte. /// Percentiles are computed from the last 10,000 transactions (if available). public func get_current_fee_percentiles() : async [MillisatoshiPerVByte] { - await BitcoinApi.get_current_fee_percentiles(NETWORK) + await BitcoinApi.get_current_fee_percentiles(NETWORK); }; /// Returns the P2PKH address of this canister at a specific derivation path. public func get_p2pkh_address() : async BitcoinAddress { - await BitcoinWallet.get_p2pkh_address(NETWORK, KEY_NAME, DERIVATION_PATH) + await BitcoinWallet.get_p2pkh_address(NETWORK, KEY_NAME, DERIVATION_PATH); }; /// Sends the given amount of bitcoin from this canister to the given address. /// Returns the transaction ID. - public func send(request : SendRequest) : async Text { - Utils.bytesToText(await BitcoinWallet.send(NETWORK, DERIVATION_PATH, KEY_NAME, request.destination_address, request.amount_in_satoshi)) + public func send_from_p2pkh_address(request : SendRequest) : async Text { + Utils.bytesToText(await BitcoinWallet.send(NETWORK, DERIVATION_PATH, KEY_NAME, request.destination_address, request.amount_in_satoshi)); + }; + + public func get_p2tr_raw_key_spend_address() : async BitcoinAddress { + await BitcoinWallet.get_p2tr_raw_key_spend_address(NETWORK, KEY_NAME, DERIVATION_PATH); }; -}; + public func send_from_p2tr_raw_key_spend_address(request : SendRequest) : async Text { + Utils.bytesToText(await BitcoinWallet.send_p2tr_raw_key_spend(NETWORK, DERIVATION_PATH, KEY_NAME, request.destination_address, request.amount_in_satoshi)); + }; +}; diff --git a/motoko/basic_bitcoin/src/basic_bitcoin/src/SchnorrApi.mo b/motoko/basic_bitcoin/src/basic_bitcoin/src/SchnorrApi.mo new file mode 100644 index 000000000..80a15ca34 --- /dev/null +++ b/motoko/basic_bitcoin/src/basic_bitcoin/src/SchnorrApi.mo @@ -0,0 +1,52 @@ +import ExperimentalCycles "mo:base/ExperimentalCycles"; + +import Types "Types"; + +module { + type SchnorrPublicKeyArgs = Types.SchnorrPublicKeyArgs; + type SchnorrPublicKeyReply = Types.SchnorrPublicKeyReply; + type SignWithSchnorrArgs = Types.SignWithSchnorrArgs; + type SignWithSchnorrReply = Types.SignWithSchnorrReply; + type Cycles = Types.Cycles; + + /// Actor definition to handle interactions with the Schnorr canister. + type SchnorrCanisterActor = actor { + schnorr_public_key : SchnorrPublicKeyArgs -> async SchnorrPublicKeyReply; + sign_with_schnorr : SignWithSchnorrArgs -> async SignWithSchnorrReply; + }; + + // The fee for the `sign_with_schnorr` endpoint using the test key. + let SIGN_WITH_SCHNORR_COST_CYCLES : Cycles = 10_000_000_000; + + let schnorr_canister_actor : SchnorrCanisterActor = actor ("aaaaa-aa"); + + /// Returns the Schnorr public key of this canister at the given derivation path. + public func schnorr_public_key(key_name : Text, derivation_path : [Blob]) : async Blob { + // Retrieve the public key of this canister at derivation path + // from the Schnorr API. + let res = await schnorr_canister_actor.schnorr_public_key({ + canister_id = null; + derivation_path; + key_id = { + algorithm = #bip340secp256k1; + name = key_name; + }; + }); + + res.public_key; + }; + + public func sign_with_schnorr(key_name : Text, derivation_path : [Blob], message : Blob) : async Blob { + ExperimentalCycles.add(SIGN_WITH_SCHNORR_COST_CYCLES); + let res = await schnorr_canister_actor.sign_with_schnorr({ + message; + derivation_path; + key_id = { + algorithm = #bip340secp256k1; + name = key_name; + }; + }); + + res.signature; + }; +}; diff --git a/motoko/basic_bitcoin/src/basic_bitcoin/src/Types.mo b/motoko/basic_bitcoin/src/basic_bitcoin/src/Types.mo index f4db5d6fd..133865120 100644 --- a/motoko/basic_bitcoin/src/basic_bitcoin/src/Types.mo +++ b/motoko/basic_bitcoin/src/basic_bitcoin/src/Types.mo @@ -36,6 +36,36 @@ module Types { key_id : EcdsaKeyId; }; + public type SchnorrKeyId = { + algorithm : SchnorrAlgorithm; + name : Text; + }; + + public type SchnorrAlgorithm = { + #bip340secp256k1; + }; + + public type SchnorrPublicKeyArgs = { + canister_id : ?Principal; + derivation_path : [Blob]; + key_id : SchnorrKeyId; + }; + + public type SchnorrPublicKeyReply = { + public_key : Blob; + chain_code : Blob; + }; + + public type SignWithSchnorrArgs = { + message : Blob; + derivation_path : [Blob]; + key_id : SchnorrKeyId; + }; + + public type SignWithSchnorrReply = { + signature : Blob; + }; + public type Satoshi = Nat64; public type MillisatoshiPerVByte = Nat64; public type Cycles = Nat; @@ -44,7 +74,7 @@ module Types { public type Page = [Nat8]; public let CURVE = Curves.secp256k1; - + /// The type of Bitcoin network the dapp will be interacting with. public type Network = { #mainnet; @@ -60,18 +90,18 @@ module Types { #Regtest; }; - public func network_to_network_camel_case(network: Network) : NetworkCamelCase { + public func network_to_network_camel_case(network : Network) : NetworkCamelCase { switch (network) { case (#regtest) { - #Regtest + #Regtest; }; case (#testnet) { - #Testnet + #Testnet; }; case (#mainnet) { - #Mainnet + #Mainnet; }; - } + }; }; /// A reference to a transaction output. @@ -124,4 +154,4 @@ module Types { transaction : [Nat8]; network : Network; }; -} +}; diff --git a/motoko/basic_bitcoin/src/basic_bitcoin/src/Utils.mo b/motoko/basic_bitcoin/src/basic_bitcoin/src/Utils.mo index 8f5b08cb9..48ae89385 100644 --- a/motoko/basic_bitcoin/src/basic_bitcoin/src/Utils.mo +++ b/motoko/basic_bitcoin/src/basic_bitcoin/src/Utils.mo @@ -9,22 +9,22 @@ module { type Result = Result.Result; /// Returns the value of the result and traps if there isn't any value to return. - public func get_ok(result : Result) : T { + public func get_ok(result : Result) : T { switch result { case (#ok value) value; case (#err error) - Debug.trap("pattern failed"); + Debug.trap("pattern failed: " # error); } }; /// Returns the value of the result and traps with a custom message if there isn't any value to return. - public func get_ok_except(result : Result, expect : Text) : T { + public func get_ok_except(result : Result, expect : Text) : T { switch result { case (#ok value) value; case (#err error) { - Debug.print("pattern failed"); + Debug.print("pattern failed: " # error); Debug.trap(expect); }; } From d85297abf77246dd907b652eb8fd322311ccc822 Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko Date: Mon, 9 Sep 2024 11:09:51 +0200 Subject: [PATCH 02/30] revert changes to motoko-bitcoin to merge the mops branch --- motoko/basic_bitcoin/motoko-bitcoin | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/motoko/basic_bitcoin/motoko-bitcoin b/motoko/basic_bitcoin/motoko-bitcoin index ca9f6eee3..10cf7f2ef 160000 --- a/motoko/basic_bitcoin/motoko-bitcoin +++ b/motoko/basic_bitcoin/motoko-bitcoin @@ -1 +1 @@ -Subproject commit ca9f6eee3b2f908ad35f9dbeb9d05ab8a3b1f4b2 +Subproject commit 10cf7f2efbba9bb4d077013db1b990143f193844 From 421fd3ed802acbad17c5abf05491dae124256d14 Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko Date: Mon, 9 Sep 2024 12:48:00 +0200 Subject: [PATCH 03/30] point motoko-bitcoin to the latest branch that implements both raw key and script spends --- motoko/basic_bitcoin/mops.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/motoko/basic_bitcoin/mops.toml b/motoko/basic_bitcoin/mops.toml index 2ddbccecc..1b64230b3 100644 --- a/motoko/basic_bitcoin/mops.toml +++ b/motoko/basic_bitcoin/mops.toml @@ -1,5 +1,5 @@ [dependencies] base = "0.11.2" -bitcoin = "https://github.com/dfinity/motoko-bitcoin#ad3709363bf980d2cab45cce0dea7eda5c97a7ff" +bitcoin = "https://github.com/dfinity/motoko-bitcoin#alex-add-script-spends" # sha2 is a transitive dependency and should be removed when `bitcoin` is published on `mops` sha2 = "0.1.0" From aaec5ce4b5452974a469cfb6cb4d5fdab8f870c7 Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko Date: Mon, 9 Sep 2024 21:52:27 +0200 Subject: [PATCH 04/30] restructure: p2pkh and p2tr in separate files --- .../src/basic_bitcoin/src/Main.mo | 11 +- .../src/basic_bitcoin/src/P2pkh.mo | 226 ++++++++++++++++++ .../{BitcoinWallet.mo => P2trRawKeySpend.mo} | 206 +--------------- .../src/basic_bitcoin/src/Types.mo | 2 + .../src/basic_bitcoin/src/Utils.mo | 10 +- 5 files changed, 253 insertions(+), 202 deletions(-) create mode 100644 motoko/basic_bitcoin/src/basic_bitcoin/src/P2pkh.mo rename motoko/basic_bitcoin/src/basic_bitcoin/src/{BitcoinWallet.mo => P2trRawKeySpend.mo} (50%) diff --git a/motoko/basic_bitcoin/src/basic_bitcoin/src/Main.mo b/motoko/basic_bitcoin/src/basic_bitcoin/src/Main.mo index 0dff2743a..612c5d956 100644 --- a/motoko/basic_bitcoin/src/basic_bitcoin/src/Main.mo +++ b/motoko/basic_bitcoin/src/basic_bitcoin/src/Main.mo @@ -1,7 +1,8 @@ import Text "mo:base/Text"; -import BitcoinWallet "BitcoinWallet"; import BitcoinApi "BitcoinApi"; +import P2pkh "P2pkh"; +import P2trRawKeySpend "P2trRawKeySpend"; import Types "Types"; import Utils "Utils"; @@ -49,20 +50,20 @@ actor class BasicBitcoin(_network : Types.Network) { /// Returns the P2PKH address of this canister at a specific derivation path. public func get_p2pkh_address() : async BitcoinAddress { - await BitcoinWallet.get_p2pkh_address(NETWORK, KEY_NAME, DERIVATION_PATH); + await P2pkh.get_address(NETWORK, KEY_NAME, DERIVATION_PATH); }; /// Sends the given amount of bitcoin from this canister to the given address. /// Returns the transaction ID. public func send_from_p2pkh_address(request : SendRequest) : async Text { - Utils.bytesToText(await BitcoinWallet.send(NETWORK, DERIVATION_PATH, KEY_NAME, request.destination_address, request.amount_in_satoshi)); + Utils.bytesToText(await P2pkh.send(NETWORK, DERIVATION_PATH, KEY_NAME, request.destination_address, request.amount_in_satoshi)); }; public func get_p2tr_raw_key_spend_address() : async BitcoinAddress { - await BitcoinWallet.get_p2tr_raw_key_spend_address(NETWORK, KEY_NAME, DERIVATION_PATH); + await P2trRawKeySpend.get_address(NETWORK, KEY_NAME, DERIVATION_PATH); }; public func send_from_p2tr_raw_key_spend_address(request : SendRequest) : async Text { - Utils.bytesToText(await BitcoinWallet.send_p2tr_raw_key_spend(NETWORK, DERIVATION_PATH, KEY_NAME, request.destination_address, request.amount_in_satoshi)); + Utils.bytesToText(await P2trRawKeySpend.send(NETWORK, DERIVATION_PATH, KEY_NAME, request.destination_address, request.amount_in_satoshi)); }; }; diff --git a/motoko/basic_bitcoin/src/basic_bitcoin/src/P2pkh.mo b/motoko/basic_bitcoin/src/basic_bitcoin/src/P2pkh.mo new file mode 100644 index 000000000..38160ce3a --- /dev/null +++ b/motoko/basic_bitcoin/src/basic_bitcoin/src/P2pkh.mo @@ -0,0 +1,226 @@ +//! A demo of a very bare-bones Bitcoin "wallet". +//! +//! The wallet here showcases how Bitcoin addresses can be computed +//! and how Bitcoin transactions can be signed. It is missing several +//! pieces that any production-grade wallet would have, including: +//! +//! * Support for address types that aren't P2PKH. +//! * Caching spent UTXOs so that they are not reused in future transactions. +//! * Option to set the fee. + +import Debug "mo:base/Debug"; +import Array "mo:base/Array"; +import Nat8 "mo:base/Nat8"; +import Nat32 "mo:base/Nat32"; +import Nat64 "mo:base/Nat64"; +import Iter "mo:base/Iter"; +import Blob "mo:base/Blob"; +import Nat "mo:base/Nat"; +import Option "mo:base/Option"; + +import EcdsaTypes "mo:bitcoin/ecdsa/Types"; +import P2pkh "mo:bitcoin/bitcoin/P2pkh"; +import Bitcoin "mo:bitcoin/bitcoin/Bitcoin"; +import Address "mo:bitcoin/bitcoin/Address"; +import Transaction "mo:bitcoin/bitcoin/Transaction"; +import TxInput "mo:bitcoin/bitcoin/TxInput"; +import Script "mo:bitcoin/bitcoin/Script"; +import Publickey "mo:bitcoin/ecdsa/Publickey"; +import Der "mo:bitcoin/ecdsa/Der"; +import Affine "mo:bitcoin/ec/Affine"; +import Segwit "mo:bitcoin/Segwit"; + +import BitcoinApi "BitcoinApi"; +import EcdsaApi "EcdsaApi"; +import SchnorrApi "SchnorrApi"; +import Types "Types"; +import Utils "Utils"; + +module { + type Network = Types.Network; + type BitcoinAddress = Types.BitcoinAddress; + type Satoshi = Types.Satoshi; + type Utxo = Types.Utxo; + type MillisatoshiPerVByte = Types.MillisatoshiPerVByte; + let CURVE = Types.CURVE; + type PublicKey = EcdsaTypes.PublicKey; + type Transaction = Transaction.Transaction; + type Script = Script.Script; + type SighashType = Nat32; + + let SIGHASH_ALL : SighashType = 0x01; + + /// Returns the P2PKH address of this canister at the given derivation path. + public func get_address(network : Network, key_name : Text, derivation_path : [[Nat8]]) : async BitcoinAddress { + // Fetch the public key of the given derivation path. + let public_key = await EcdsaApi.ecdsa_public_key(key_name, Array.map(derivation_path, Blob.fromArray)); + + // Compute the address. + public_key_to_p2pkh_address(network, Blob.toArray(public_key)); + }; + + /// Sends a transaction to the network that transfers the given amount to the + /// given destination, where the source of the funds is the canister itself + /// at the given derivation path. + public func send(network : Network, derivation_path : [[Nat8]], key_name : Text, dst_address : BitcoinAddress, amount : Satoshi) : async [Nat8] { + // Get fee percentiles from previous transactions to estimate our own fee. + let fee_percentiles = await BitcoinApi.get_current_fee_percentiles(network); + + let fee_per_vbyte : MillisatoshiPerVByte = if (fee_percentiles.size() == 0) { + // There are no fee percentiles. This case can only happen on a regtest + // network where there are no non-coinbase transactions. In this case, + // we use a default of 1000 millisatoshis/vbyte (i.e. 2 satoshi/byte) + 2000; + } else { + // Choose the 50th percentile for sending fees. + fee_percentiles[50]; + }; + + // Fetch our public key, P2PKH address, and UTXOs. + let own_public_key = Blob.toArray(await EcdsaApi.ecdsa_public_key(key_name, Array.map(derivation_path, Blob.fromArray))); + let own_address = public_key_to_p2pkh_address(network, own_public_key); + + // Note that pagination may have to be used to get all UTXOs for the given address. + // For the sake of simplicity, it is assumed here that the `utxo` field in the response + // contains all UTXOs. + let own_utxos = (await BitcoinApi.get_utxos(network, own_address)).utxos; + + // Build the transaction that sends `amount` to the destination address. + let tx_bytes = await build_transaction(own_public_key, own_address, own_utxos, dst_address, amount, fee_per_vbyte); + let transaction = Utils.get_ok(Transaction.fromBytes(Iter.fromArray(tx_bytes))); + + // Sign the transaction. + let signed_transaction_bytes = await sign_transaction(own_public_key, own_address, transaction, key_name, Array.map(derivation_path, Blob.fromArray), EcdsaApi.sign_with_ecdsa); + let signed_transaction = Utils.get_ok(Transaction.fromBytes(Iter.fromArray(signed_transaction_bytes))); + + Debug.print("Sending transaction"); + await BitcoinApi.send_transaction(network, signed_transaction_bytes); + + signed_transaction.id(); + }; + + // Builds a transaction to send the given `amount` of satoshis to the + // destination address. + func build_transaction( + own_public_key : [Nat8], + own_address : BitcoinAddress, + own_utxos : [Utxo], + dst_address : BitcoinAddress, + amount : Satoshi, + fee_per_vbyte : MillisatoshiPerVByte, + ) : async [Nat8] { + let dst_address_typed = Utils.get_ok_except(Address.addressFromText(dst_address), "failed to decode destination address"); + + // We have a chicken-and-egg problem where we need to know the length + // of the transaction in order to compute its proper fee, but we need + // to know the proper fee in order to figure out the inputs needed for + // the transaction. + // + // We solve this problem iteratively. We start with a fee of zero, build + // and sign a transaction, see what its size is, and then update the fee, + // rebuild the transaction, until the fee is set to the correct amount. + let fee_per_vbyte_nat = Nat64.toNat(fee_per_vbyte); + Debug.print("Building transaction..."); + var total_fee : Nat = 0; + loop { + let transaction = Utils.get_ok_except(Bitcoin.buildTransaction(2, own_utxos, [(dst_address_typed, amount)], #p2pkh own_address, Nat64.fromNat(total_fee)), "Error building transaction."); + + // Sign the transaction. In this case, we only care about the size + // of the signed transaction, so we use a mock signer here for efficiency. + let signed_transaction_bytes = await sign_transaction( + own_public_key, + own_address, + transaction, + "", // mock key name + [], // mock derivation path + Utils.mock_signer, + ); + + let signed_tx_bytes_len : Nat = signed_transaction_bytes.size(); + + if ((signed_tx_bytes_len * fee_per_vbyte_nat) / 1000 == total_fee) { + Debug.print("Transaction built with fee " # debug_show (total_fee)); + return transaction.toBytes(); + } else { + total_fee := (signed_tx_bytes_len * fee_per_vbyte_nat) / 1000; + }; + }; + }; + + // Sign a bitcoin transaction. + // + // IMPORTANT: This method is for demonstration purposes only and it only + // supports signing transactions if: + // + // 1. All the inputs are referencing outpoints that are owned by `own_address`. + // 2. `own_address` is a P2PKH address. + func sign_transaction( + own_public_key : [Nat8], + own_address : BitcoinAddress, + transaction : Transaction, + key_name : Text, + derivation_path : [Blob], + signer : Types.SignFunction, + ) : async [Nat8] { + // Obtain the scriptPubKey of the source address which is also the + // scriptPubKey of the Tx output being spent. + switch (Address.scriptPubKey(#p2pkh own_address)) { + case (#ok scriptPubKey) { + let scriptSigs = Array.init