diff --git a/docs/fidelity-bonds.md b/docs/fidelity-bonds.md new file mode 100644 index 0000000..d6c2c64 --- /dev/null +++ b/docs/fidelity-bonds.md @@ -0,0 +1,271 @@ +# Fidelity bonds + +Fidelity bonds are a feature of JoinMarket which improves the resistance to +sybil attacks, and therefore improves the privacy of the system. + +## This feature is incomplete and so is disabled for now + +A fidelity bond is a mechanism where bitcoin value is deliberately sacrificed +to make a cryptographic identity expensive to obtain. The sacrifice is done in +a way that can be proven to a third party. Takers in JoinMarket will +have a higher probability to create coinjoins with makers who publish more +valuable fidelity bonds. This has the effect of making the system much more +expensive to sybil attack, because an attacker would have to sacrifice a lot of +value in order to be chosen very often by takers when creating coinjoin. + +As a maker you can take part in many more coinjoins and therefore earn more +fees if you sacrifice bitcoin value to create a fidelity bond. The most +practical way to create a fidelity bond is to send bitcoins to a time-locked +address which uses the opcode [OP_CHECKLOCKTIMEVERIFY](https://en.bitcoin.it/wiki/Timelock#CheckLockTimeVerify). +The valuable thing being sacrificed is the time-value-of-money. Note that a +long-term holder (or hodler) of bitcoins can buy time-locked fidelity bonds +essentially for free, assuming they never intended to transact with their coins +anyway. + +Another way to create fidelity bonds is to destroy coins by sending them to a +[OP_RETURN](https://en.bitcoin.it/wiki/Script#Provably_Unspendable.2FPrunable_Outputs) +output. + +The private keys to fidelity bonds can be kept in [cold storage](https://en.bitcoin.it/wiki/Cold_storage) +for added security. + +For a more detailed explanation of how fidelity bonds work see these documents: + +* [Design for improving JoinMarket's resistance to sybil attacks using fidelity +bonds](https://gist.github.com/chris-belcher/18ea0e6acdb885a2bfbdee43dcd6b5af/) + +* [Financial mathematics of JoinMarket fidelity bonds](https://gist.github.com/chris-belcher/87ebbcbb639686057a389acb9ab3e25b) + +#### Note on privacy + +Bitcoin outputs which create fidelity bonds will be published to the entire +world, so before and after creating them make sure the outputs are not linked +to your identity in any way. Perhaps mix with JoinMarket before and after. + +### Creating a JoinMarket wallet which supports fidelity bonds + +When generating a JoinMarket wallet on the command line, supporting versions +will offer an option to make the wallet support fidelity bonds. + + (jmvenv) $ python3 wallet-tool.py generate + Would you like to use a two-factor mnemonic recovery phrase? write 'n' if you don't know what this is (y/n): n + Not using mnemonic extension + Enter wallet file encryption passphrase: + Reenter wallet file encryption passphrase: + Input wallet file name (default: wallet.jmdat): testfidelity.jmdat + Would you like this wallet to support fidelity bonds? write 'n' if you don't know what this is (y/n): y + Write down this wallet recovery mnemonic + + evidence initial knee image inspire plug dad midnight blast awkward clean between + + Generated wallet OK + +As always, it is crucially important to write down the 12-word [seed phrase](https://en.bitcoin.it/wiki/Seed_phrase) +as a backup. It is also recommended to write down the name of the creating wallet +"JoinMarket" and that the fidelity bond option was enabled. Writing the wallet +creation date is also useful as it can help with rescanning. + +### Obtaining time-locked addresses + +The `wallet-tool.py` script supports a new method `gettimelockaddress` used for +obtaining time-locked addresses. If coins are sent to these addresses they will +be locked up until the timelock date passes. Only mixdepth zero can have +fidelity bonds in it. + +This example creates an address which locks any coins sent to it until April 2020. + + (jmvenv) $ python3 wallet-tool.py testfidelity.jmdat gettimelockaddress 2020-4 + Enter wallet decryption passphrase: + path = m/49'/1'/0'/2/3:1585699200 + Coins sent to this address will be not be spendable until April 2020. Full date: 2020-04-01 00:00:00 + bcrt1qrc2qu3m2l2spayu5kr0k0rnn9xgjz46zsxmruh87a3h3f5zmnkaqlfx7v5 + +If coins are sent to these addresses they will appear in the usual `wallet-tool.py` +display: + + (jmvenv) $ python3 wallet-tool.py -m 0 testfidelity.jmdat + Enter wallet decryption passphrase: + JM wallet + mixdepth 0 fbonds-mpk-tpubDDCbCPdf5wJVGYWB4mZr3E3Lys4NBcEKysrrUrLfhG6sekmrvs6KZNe4i5p5z3FyfwRmKMqB9NWEcEUiTS4LwqfrKPQzhKj6aLihu2EejaU + external addresses m/49'/1'/0'/0 tpubDEGdmPwmQRcZmGKhaudjch9Fgw4J5yP4bYw5B8LoSDkMdmhBxM4ndEQXHK4r1TPexGjLidxdpeEzsJcdXEe7khWToxCZuN6JiLzvUoHAki2 + m/49'/1'/0'/0/0 2N8jHuQaApgFtQ8UKxKbREAvNxKn4BGX4x2 0.00000000 new + m/49'/1'/0'/0/1 2Mx5CwDoNcuCT38EDmgenQxv9skHbZfXFdo 0.00000000 new + m/49'/1'/0'/0/2 2N1tNTTwNyucGGmfDWNVk3AUi3i5S8jVKqn 0.00000000 new + m/49'/1'/0'/0/3 2N8eBEU5wpWb6kS1gvbRgewtxsmXsMkShV6 0.00000000 new + m/49'/1'/0'/0/4 2MuHgeSgMsvkcn6aGNW2uk2UXP3xVVnkfh2 0.00000000 new + m/49'/1'/0'/0/5 2NA8d8um5KmBNNR8dadhbEDYGiTJPFCdjMB 0.00000000 new + Balance: 0.00000000 + internal addresses m/49'/1'/0'/1 + Balance: 0.00000000 + internal addresses m/49'/1'/0'/2 tpubDEGdmPwmQRcZrzjRmUFqXXyLdRedwxCWQviAFqDe6sXJeZzRNTwmwqMfxN6Ka3v7hEebstrU5kqUNoHsFKaA3RoB2vopL6kLHVo1EQq6USw + m/49'/1'/0'/2/0:1585699200 bcrt1qrc2qu3m2l2spayu5kr0k0rnn9xgjz46zsxmruh87a3h3f5zmnkaqlfx7v5 0.15000000 2020-04-01 [LOCKED] + Balance: 0.15000000 + Balance for mixdepth 0: 0.15000000 + Total balance: 0.15000000 + +#### Spending time-locked coins + +Once the time-lock of an address expires the coins can be spent with JoinMarket. + +Coins living on time-locked addresses are automatically frozen with +JoinMarket's coin control feature, so before spending you need to unfreeze the +coins using `python3 wallet-tool.py -m 0 freeze`. + +Once unfrozen and untimelocked the coins can be spent normally with the scripts +`sendpayment.py`, `tumber.py`, or yield generator. + +### Burning coins + +Coins can be burned with a special method of the `sendpayment.py` script. Set +the destination to be `BURN`. Transactions which burn coins must only have one +input and one output, so use coin control to freeze all coins in the zeroth +mixdepth except one. + + $ python3 sendpayment.py -N 0 testfidelity3.jmdat 0 BURN + User data location: . + 2020-04-07 20:45:25,658 [INFO] Using this min relay fee as tx fee floor: 1000 sat/vkB (1.0 sat/vB) + Enter wallet decryption passphrase: + 2020-04-07 20:46:50,449 [INFO] Estimated miner/tx fees for this coinjoin amount: 0.0% + 2020-04-07 20:46:50,452 [INFO] Using this min relay fee as tx fee floor: 1000 sat/vkB (1.0 sat/vB) + 2020-04-07 20:46:50,452 [INFO] Using a fee of : 0.00000200 BTC (200 sat). + 2020-04-07 20:46:50,454 [INFO] Got signed transaction: + + 2020-04-07 20:46:50,455 [INFO] {'ins': [{'outpoint': {'hash': '61d69b4e7abe0ef8a5a9cbabb05463259c3b497a142130a56f81a9259f048cb0', + 'index': 0}, + 'script': '160014295beb4eba9b35896683d5b5ff455ee2c646054c', + 'sequence': 4294967294, + 'txinwitness': ['3045022100939de908e30015c6b22d2c0f25153e395268466ce44eeeb4ec03a8920440e87b0220155d1c43dedb3fc2654205541bb2821dd5211180e2d7f93d67301470652830d401', + '03ec0f8b267f99ff5259195ce63813d58f38ffbaada894ce06af0c0303c74bbf82']}], + 'locktime': 1361, + 'outs': [{'script': '6a147631c805d8ad9239677b8d7530353fda3fec07ca', + 'value': 11999800}], + 'version': 2} + 2020-04-07 20:46:50,455 [INFO] In serialized form (for copy-paste): + 2020-04-07 20:46:50,455 [INFO] 02000000000101b08c049f25a9816fa53021147a493b9c256354b0abcba9a5f80ebe7a4e9bd6610000000017160014295beb4eba9b35896683d5b5ff455ee2c646054cfeffffff01381ab70000000000166a147631c805d8ad9239677b8d7530353fda3fec07ca02483045022100939de908e30015c6b22d2c0f25153e395268466ce44eeeb4ec03a8920440e87b0220155d1c43dedb3fc2654205541bb2821dd5211180e2d7f93d67301470652830d4012103ec0f8b267f99ff5259195ce63813d58f38ffbaada894ce06af0c0303c74bbf8251050000 + 2020-04-07 20:46:50,456 [INFO] Sends: 0.11999800 BTC (11999800 sat) to destination: BURNER OUTPUT embedding pubkey at m/49'/1'/0'/3/0 + + WARNING: This transaction if broadcasted will PERMANENTLY DESTROY your bitcoins + + Would you like to push to the network? (y/n):y + 2020-04-07 20:47:52,047 [DEBUG] rpc: sendrawtransaction ['02000000000101b08c049f25a9816fa53021147a493b9c256354b0abcba9a5f80ebe7a4e9bd6610000000017160014295beb4eba9b35896683d5b5ff455ee2c646054cfeffffff01381ab70000000000166a147631c805d8ad9239677b8d7530353fda3fec07ca02483045022100939de908e30015c6b22d2c0f25153e395268466ce44eeeb4ec03a8920440e87b0220155d1c43dedb3fc2654205541bb2821dd5211180e2d7f93d67301470652830d4012103ec0f8b267f99ff5259195ce63813d58f38ffbaada894ce06af0c0303c74bbf8251050000'] + 2020-04-07 20:47:52,049 [WARNING] Connection had broken pipe, attempting reconnect. + 2020-04-07 20:47:52,440 [INFO] Transaction sent: 656bb4538f14f2cc874043915907b6c9c46a807ef9818bde771d07630d54b0f7 + done + +Embedded in the `OP_RETURN` output is the hash of a pubkey from the wallet. + +Now `OP_RETURN` outputs are not addresses, and because of technical reasons the +first time they are synchronized the flag `--recoversync` must be used. When +this is done the burn output will appear in the `wallet-tool.py` display. +`--recoversync` only needs to be used once, and after that the burner output is +saved in the `wallet.jmdat` file which can be accesses by all future +synchronizations. + + $ python3 wallet-tool.py --datadir=. --recoversync testfidelity2.jmdat + Enter wallet decryption passphrase: + 2020-04-07 23:09:54,644 [INFO] Found a burner transaction txid=656bb4538f14f2cc874043915907b6c9c46a807ef9818bde771d07630d54b0f7 path = m/49'/1'/0'/3/0 + 2020-04-07 23:09:54,769 [WARNING] Merkle branch not available, use wallet-tool `addtxoutproof` + 2020-04-07 23:09:55,420 [INFO] Found a burner transaction txid=656bb4538f14f2cc874043915907b6c9c46a807ef9818bde771d07630d54b0f7 path = m/49'/1'/0'/3/0 + 2020-04-07 23:09:55,422 [WARNING] Merkle branch not available, use wallet-tool `addtxoutproof` + JM wallet + mixdepth 0 fbonds-mpk-tpubDDCXgSpdxuVbXgzRYBggFeMRNeV9eH24jJuQNunyqwYtDFiB7ZS63LhXwHkf7o9ZcZW4qUz7uvD6yk4BkkF3bBPmJRPv7RBTEA5hHwEdV2f + external addresses m/49'/1'/0'/0 tpubDEJGorVywRb6LoLQbaqWZh2gYwpdZqViCNZ2ejB5kpBuUp16LHpK6ESFaJLixidtbmmjcDwVZ4QjnAbKmypfuGaEk3ifgonPv4vsugqgp9G + m/49'/1'/0'/0/2 2MviB2FfLKZjFb3W2dJ8kXcQBj3jqMJg7TL 0.00000000 new + m/49'/1'/0'/0/3 2MtsAQhE2u9VGV3aZ7XM4PzwWGHXr4PAhqP 0.00000000 new + m/49'/1'/0'/0/4 2N3iXNjS4vkTXzy5Jidnovc6FJeNQJx5Fnt 0.00000000 new + m/49'/1'/0'/0/5 2MtT4XAjwQQ7PBbrTxv7qcQMMmz4Rs5XrE3 0.00000000 new + m/49'/1'/0'/0/6 2NEuG23BQESuZTqSDtab9zYsd1Jb4KfMULB 0.00000000 new + m/49'/1'/0'/0/7 2N6NGJRX6KYQWtYWK8iHFuJNpZRs8NbUAC9 0.00000000 new + Balance: 0.00000000 + internal addresses m/49'/1'/0'/1 + Balance: 0.00000000 + internal addresses m/49'/1'/0'/2 tpubDEJGorVywRb6T34X7ZAEz9hQYn6CCEhrcFa8kA2mqNau2DvoggZP2QTtXRe8t9NSfMkx3ye8QDzqCE9gEqso6fw5ALk5xycWLFwTRLSqSUV + Balance: 0.00000000 + internal addresses m/49'/1'/0'/3 tpubDEJGorVywRb6V5em9Q7LFJ9eLEAEZmZxUdDmkknrKNUs7vKcCWPKwP8YPjuxFCCXk2F1wJnubNbmgbtWed5yiE3D1qxzLonVuXT6QEZPaof + m/49'/1'/0'/3/0 BURN-7631c805d8ad9239677b8d7530353fda3fec07ca 0.11999800 656bb4538f14f2cc874043915907b6c9c46a807ef9818bde771d07630d54b0f7 [NO MERKLE PROOF] + Balance: 0.11999800 + Balance for mixdepth 0: 0.11999800 + Total balance: 0.11999800 + +#### Adding the merkle proof of a burn transaction if missing + +In order to prove a burn output exists, a merkle proof is needed. If the Core +node is pruned and the block deleted then JoinMarket will not be able to obtain +the merkle proof (as in the above example). In this case the proof can be +added separately. + +Any other unpruned Core node can trustlessly obtain the proof and give it to +the user with the RPC call `gettxoutproof`. + +First obtain the merkle proof: + + $ bitcoin-cli gettxoutproof "[\"656bb4538f14f2cc874043915907b6c9c46a807ef9818bde771d07630d54b0f7\"]" 4cce28f4eb1ea1762ec4ceb90529b3ab28c0423ac630c6292319e2b2712daada + 0000002056e4050f54084a1d6e6944b209cce76ebe2da4b37f3aa47ab6c612831d3220471015e80d3050cf0ee05b216036030fe3d4906221196943a30e828741ad4cfaeb05d98c5effff7f20010000000200000002a5910e5cf4e6cb6d55e1e2ca979987772a482e8a8f30b7a6cab8c5671f5c161df7b0540d63071d77de8b81f97e806ac4c9b6075991434087ccf2148f53b46b650105 + +Then add it to the JoinMarket wallet: + + $ python3 wallet-tool.py -H "m/49'/1'/0'/3/0" testfidelity2.jmdat addtxoutproof 0000002056e4050f54084a1d6e6944b209cce76ebe2da4b37f3aa47ab6c612831d3220471015e80d3050cf0ee05b216036030fe3d4906221196943a30e828741ad4cfaeb05d98c5effff7f20010000000200000002a5910e5cf4e6cb6d55e1e2ca979987772a482e8a8f30b7a6cab8c5671f5c161df7b0540d63071d77de8b81f97e806ac4c9b6075991434087ccf2148f53b46b650105 + Enter wallet decryption passphrase: + Done + +The `-H` flag must point to the path containing the burn output. + +Then synchronizing the wallet won't output the no-merkle-proof warning. + +### Creating watch-only fidelity bond wallets + +Fidelity bonds can be held on an offline computer in +[cold storage](https://en.bitcoin.it/wiki/Cold_storage). To do this we create +a watch-only fidelity bond wallet. + +When fidelity bonds are displayed in `wallet-tool.py`, their master public key +is highlighted with a prefix `fbonds-mpk-`. + +This master public key can be used to create a watch-only wallet using +`wallet-tool.py`. + + $ python3 wallet-tool.py createwatchonly fbonds-mpk-tpubDDCbCPdf5wJVGYWB4mZr3E3Lys4NBcEKysrrUrLfhG6sekmrvs6KZNe4i5p5z3FyfwRmKMqB9NWEcEUiTS4LwqfrKPQzhKj6aLihu2EejaU + Input wallet file name (default: watchonly.jmdat): watchfidelity.jmdat + Enter wallet file encryption passphrase: + Reenter wallet file encryption passphrase: + Done + +Then the wallet can be displayed like a regular wallet, although only the zeroth +mixdepth will be shown. + + $ python3 wallet-tool.py watchfidelity.jmdat + User data location: . + Enter wallet decryption passphrase: + JM wallet + mixdepth 0 fbonds-mpk-tpubDDCbCPdf5wJVGYWB4mZr3E3Lys4NBcEKysrrUrLfhG6sekmrvs6KZNe4i5p5z3FyfwRmKMqB9NWEcEUiTS4LwqfrKPQzhKj6aLihu2EejaU + external addresses m/49'/1'/0'/0 tpubDEGdmPwmQRcZmGKhaudjch9Fgw4J5yP4bYw5B8LoSDkMdmhBxM4ndEQXHK4r1TPexGjLidxdpeEzsJcdXEe7khWToxCZuN6JiLzvUoHAki2 + m/49'/1'/0'/0/0 2N8jHuQaApgFtQ8UKxKbREAvNxKn4BGX4x2 0.00000000 used + m/49'/1'/0'/0/1 2Mx5CwDoNcuCT38EDmgenQxv9skHbZfXFdo 0.00000000 new + m/49'/1'/0'/0/2 2N1tNTTwNyucGGmfDWNVk3AUi3i5S8jVKqn 0.00000000 new + m/49'/1'/0'/0/3 2N8eBEU5wpWb6kS1gvbRgewtxsmXsMkShV6 0.00000000 new + m/49'/1'/0'/0/4 2MuHgeSgMsvkcn6aGNW2uk2UXP3xVVnkfh2 0.00000000 new + m/49'/1'/0'/0/5 2NA8d8um5KmBNNR8dadhbEDYGiTJPFCdjMB 0.00000000 new + m/49'/1'/0'/0/6 2NG76BAHPccfyy6sH68EHrB9QJBycx3FKb6 0.00000000 new + Balance: 0.25000000 + internal addresses m/49'/1'/0'/1 + Balance: 0.00000000 + internal addresses m/49'/1'/0'/2 tpubDEGdmPwmQRcZrzjRmUFqXXyLdRedwxCWQviAFqDe6sXJeZzRNTwmwqMfxN6Ka3v7hEebstrU5kqUNoHsFKaA3RoB2vopL6kLHVo1EQq6USw + m/49'/1'/0'/0/3:1585699200 bcrt1qrc2qu3m2l2spayu5kr0k0rnn9xgjz46zsxmruh87a3h3f5zmnkaqlfx7v5 0.15000000 2020-04-01 [UNLOCKED] + Balance: 0.15000000 + internal addresses m/49'/1'/0'/3 tpubDEGdmPwmQRcZuX3uNrCouu5bRgp2GJcoQTvhkFAJMTA3yxhKmQyeGwecbnkms4DYmBhCJn2fGTuejTe3g8oyJW3qKcfB4b3Swj2hDk1h4Y2 + Balance: 0.00000000 + Balance for mixdepth 0: 0.15000000 + Total balance: 0.15000000 + +### BIP32 Paths + +Fidelity bond wallets extend the BIP32 path format to include the locktime +values. In this example we've got `m/49'/1'/0'/2/0:1583020800` where the +number after the colon is the locktime value in Unix time. + +This path can be passed to certain wallet methods like `dumpprivkey`. + + $ python3 wallet-tool.py -H "m/49'/1'/0'/2/0:1583020800" testfidelity.jmdat dumpprivkey + Enter wallet decryption passphrase: + cNEuE5ypNTxVFCyC5iH7u5AQTrddamcUHRPNweiLvmHUWd6XXDkz + diff --git a/jmbitcoin/jmbitcoin/secp256k1_deterministic.py b/jmbitcoin/jmbitcoin/secp256k1_deterministic.py index fea699f..1baad53 100644 --- a/jmbitcoin/jmbitcoin/secp256k1_deterministic.py +++ b/jmbitcoin/jmbitcoin/secp256k1_deterministic.py @@ -65,6 +65,8 @@ def bip32_deserialize(data): def raw_bip32_privtopub(rawtuple): vbytes, depth, fingerprint, i, chaincode, key = rawtuple + if vbytes in PUBLIC: + return rawtuple newvbytes = MAINNET_PUBLIC if vbytes == MAINNET_PRIVATE else TESTNET_PUBLIC return (newvbytes, depth, fingerprint, i, chaincode, privtopub(key, False)) diff --git a/jmbitcoin/jmbitcoin/secp256k1_main.py b/jmbitcoin/jmbitcoin/secp256k1_main.py index 931b424..e91fc59 100644 --- a/jmbitcoin/jmbitcoin/secp256k1_main.py +++ b/jmbitcoin/jmbitcoin/secp256k1_main.py @@ -10,7 +10,7 @@ import coincurve as secp256k1 #Required only for PoDLE calculation: N = 115792089237316195423570985008687907852837564279074904382605163141518161494337 -BTC_P2PK_VBYTE = {"mainnet": b'\x00', "testnet": b'\x6f'} +BTC_P2PK_VBYTE = {"mainnet": b'\x00', "testnet": b'\x6f', "regtest": 100} BTC_P2SH_VBYTE = {"mainnet": b'\x05', "testnet": b'\xc4'} #Standard prefix for Bitcoin message signing. diff --git a/jmbitcoin/jmbitcoin/secp256k1_transaction.py b/jmbitcoin/jmbitcoin/secp256k1_transaction.py index a4893cf..b8982da 100644 --- a/jmbitcoin/jmbitcoin/secp256k1_transaction.py +++ b/jmbitcoin/jmbitcoin/secp256k1_transaction.py @@ -10,6 +10,7 @@ import struct import random from jmbitcoin.secp256k1_main import * from jmbitcoin.bech32 import * +import jmbitcoin as btc P2PKH_PRE, P2PKH_POST = b'\x76\xa9\x14', b'\x88\xac' P2SH_P2WPKH_PRE, P2SH_P2WPKH_POST = b'\xa9\x14', b'\x87' @@ -534,23 +535,29 @@ def pubkey_to_p2wpkh_address(pub): script = pubkey_to_p2wpkh_script(pub) return script_to_address(script) -def pubkeys_to_p2wsh_script(pubs): +def pubkeys_to_p2wsh_multisig_script(pubs): """ Given a list of N pubkeys, constructs an N of N multisig scriptPubKey of type pay-to-witness-script-hash. No other scripts than N-N multisig supported as of now. """ N = len(pubs) script = mk_multisig_script(pubs, N) - return P2WSH_PRE + bin_sha256(binascii.unhexlify(script)) + return redeem_script_to_p2wsh_script(script) -def pubkeys_to_p2wsh_address(pubs): +def pubkeys_to_p2wsh_multisig_address(pubs): """ Given a list of N pubkeys, constructs an N of N multisig address of type pay-to-witness-script-hash. No other scripts than N-N multisig supported as of now. """ - script = pubkeys_to_p2wsh_script(pubs) + script = pubkeys_to_p2wsh_multisig_script(pubs) return script_to_address(script) +def redeem_script_to_p2wsh_script(redeem_script): + return P2WSH_PRE + bin_sha256(binascii.unhexlify(redeem_script)) + +def redeem_script_to_p2wsh_address(redeem_script, vbyte, witver=0): + return script_to_address(redeem_script_to_p2wsh_script(redeem_script), vbyte, witver) + def deserialize_script(scriptinp): """ Note that this is not used internally, in the jmbitcoin package, to deserialize() transactions; @@ -603,10 +610,14 @@ def deserialize_script(scriptinp): def serialize_script_unit(unit): if isinstance(unit, int): - if unit < 16: + if unit == 0: + return from_int_to_byte(unit) + elif unit < 16: return from_int_to_byte(unit + 80) - else: + elif unit < 256: return from_int_to_byte(unit) + else: + return b'\x04' + struct.pack(b" 1 + return cls._walk_bip32_path(master_key, cls.get_watchonly_path( + path))[-1] + + @classmethod + def key_to_script(cls, pubkey_locktime): + pub, locktime = pubkey_locktime + return cls.pubkey_to_script((pub, locktime)) + + @classmethod + def privkey_to_wif(cls, privkey_locktime): + return "" + + @classmethod + def sign_transaction(cls, tx, index, privkey, amount, + hashcode=btc.SIGHASH_ALL, **kwargs): + raise RuntimeError("Cannot spend from watch-only wallets") + +class BTC_Watchonly_P2SH_P2WPKH(BTC_P2SH_P2WPKH): + + @classmethod + def derive_bip32_privkey(cls, master_key, path): + return BTC_Watchonly_Timelocked_P2WSH.derive_bip32_privkey(master_key, path) + + @classmethod + def privkey_to_wif(cls, privkey_locktime): + return BTC_Watchonly_Timelocked_P2WSH.privkey_to_wif(privkey_locktime) + + @staticmethod + def privkey_to_pubkey(privkey): + #in watchonly wallets there are no privkeys, so functions + # like _get_key_from_path() actually return pubkeys and + # this function is a noop + return privkey + + @classmethod + def derive_bip32_pub_export(cls, master_key, path): + return super(BTC_Watchonly_P2SH_P2WPKH, cls).derive_bip32_pub_export( + master_key, BTC_Watchonly_Timelocked_P2WSH.get_watchonly_path(path)) + + @classmethod + def sign_transaction(cls, tx, index, privkey, amount, + hashcode=btc.SIGHASH_ALL, **kwargs): + raise RuntimeError("Cannot spend from watch-only wallets") + ENGINES = { TYPE_P2PKH: BTC_P2PKH, TYPE_P2SH_P2WPKH: BTC_P2SH_P2WPKH, - TYPE_P2WPKH: BTC_P2WPKH -} \ No newline at end of file + TYPE_P2WPKH: BTC_P2WPKH, + TYPE_TIMELOCK_P2WSH: BTC_Timelocked_P2WSH, + TYPE_WATCHONLY_TIMELOCK_P2WSH: BTC_Watchonly_Timelocked_P2WSH, + TYPE_WATCHONLY_P2SH_P2WPKH: BTC_Watchonly_P2SH_P2WPKH +} diff --git a/jmclient/jmclient/taker_utils.py b/jmclient/jmclient/taker_utils.py index 20630bb..6f7f294 100644 --- a/jmclient/jmclient/taker_utils.py +++ b/jmclient/jmclient/taker_utils.py @@ -5,13 +5,16 @@ import os import sys import time import numbers +from binascii import hexlify + from jmbase import get_log, jmprint -from .configure import jm_single, validate_address +from .configure import jm_single, validate_address, is_burn_destination from .schedule import human_readable_schedule_entry, tweak_tumble_schedule,\ schedule_to_text -from .wallet import BaseWallet, estimate_tx_fee, compute_tx_locktime +from .wallet import BaseWallet, estimate_tx_fee, compute_tx_locktime, \ + FidelityBondMixin from jmbitcoin import deserialize, make_shuffled_tx, serialize, txhash,\ - amount_to_str + amount_to_str, mk_burn_script, bin_hash160 from jmbase.support import EXIT_SUCCESS log = get_log() @@ -20,7 +23,7 @@ Utility functions for tumbler-style takers; Currently re-used by CLI script tumbler.py and joinmarket-qt """ -def direct_send(wallet_service, amount, mixdepth, destaddr, answeryes=False, +def direct_send(wallet_service, amount, mixdepth, destination, answeryes=False, accept_callback=None, info_callback=None): """Send coins directly from one mixdepth to one destination address; does not need IRC. Sweep as for normal sendpayment (set amount=0). @@ -41,24 +44,65 @@ def direct_send(wallet_service, amount, mixdepth, destaddr, answeryes=False, The txid if transaction is pushed, False otherwise """ #Sanity checks - assert validate_address(destaddr)[0] + assert validate_address(destination)[0] or is_burn_destination(destination) assert isinstance(mixdepth, numbers.Integral) assert mixdepth >= 0 assert isinstance(amount, numbers.Integral) assert amount >=0 assert isinstance(wallet_service.wallet, BaseWallet) + if is_burn_destination(destination): + #Additional checks + if not isinstance(wallet_service.wallet, FidelityBondMixin): + log.error("Only fidelity bond wallets can burn coins") + return + if answeryes: + log.error("Burning coins not allowed without asking for confirmation") + return + if mixdepth != FidelityBondMixin.FIDELITY_BOND_MIXDEPTH: + log.error("Burning coins only allowed from mixdepth " + str( + FidelityBondMixin.FIDELITY_BOND_MIXDEPTH)) + return + if amount != 0: + log.error("Only sweeping allowed when burning coins, to keep the tx " + + "small. Tip: use the coin control feature to freeze utxos") + return + from pprint import pformat txtype = wallet_service.get_txtype() if amount == 0: utxos = wallet_service.get_utxos_by_mixdepth()[mixdepth] if utxos == {}: log.error( - "There are no utxos in mixdepth: " + str(mixdepth) + ", quitting.") + "There are no available utxos in mixdepth: " + str(mixdepth) + ", quitting.") return + total_inputs_val = sum([va['value'] for u, va in iteritems(utxos)]) - fee_est = estimate_tx_fee(len(utxos), 1, txtype=txtype) - outs = [{"address": destaddr, "value": total_inputs_val - fee_est}] + + if is_burn_destination(destination): + if len(utxos) > 1: + log.error("Only one input allowed when burning coins, to keep " + + "the tx small. Tip: use the coin control feature to freeze utxos") + return + address_type = FidelityBondMixin.BIP32_BURN_ID + index = wallet_service.wallet.get_next_unused_index(mixdepth, address_type) + path = wallet_service.wallet.get_path(mixdepth, address_type, index) + privkey, engine = wallet_service.wallet._get_key_from_path(path) + pubkey = engine.privkey_to_pubkey(privkey) + pubkeyhash = bin_hash160(pubkey) + + #size of burn output is slightly different from regular outputs + burn_script = mk_burn_script(pubkeyhash) #in hex + fee_est = estimate_tx_fee(len(utxos), 0, txtype=txtype, extra_bytes=len(burn_script)/2) + + outs = [{"script": burn_script, "value": total_inputs_val - fee_est}] + destination = "BURNER OUTPUT embedding pubkey at " \ + + wallet_service.wallet.get_path_repr(path) \ + + "\n\nWARNING: This transaction if broadcasted will PERMANENTLY DESTROY your bitcoins\n" + else: + #regular send (non-burn) + fee_est = estimate_tx_fee(len(utxos), 1, txtype=txtype) + outs = [{"address": destination, "value": total_inputs_val - fee_est}] else: #8 inputs to be conservative initial_fee_est = estimate_tx_fee(8,2, txtype=txtype) @@ -69,30 +113,46 @@ def direct_send(wallet_service, amount, mixdepth, destaddr, answeryes=False, fee_est = initial_fee_est total_inputs_val = sum([va['value'] for u, va in iteritems(utxos)]) changeval = total_inputs_val - fee_est - amount - outs = [{"value": amount, "address": destaddr}] + outs = [{"value": amount, "address": destination}] change_addr = wallet_service.get_internal_addr(mixdepth) outs.append({"value": changeval, "address": change_addr}) + #compute transaction locktime, has special case for spending timelocked coins + tx_locktime = compute_tx_locktime() + if mixdepth == FidelityBondMixin.FIDELITY_BOND_MIXDEPTH and \ + isinstance(wallet_service.wallet, FidelityBondMixin): + for outpoint, utxo in utxos.items(): + path = wallet_service.script_to_path( + wallet_service.addr_to_script(utxo["address"])) + if not FidelityBondMixin.is_timelocked_path(path): + continue + path_locktime = path[-1] + tx_locktime = max(tx_locktime, path_locktime+1) + #compute_tx_locktime() gives a locktime in terms of block height + #timelocked addresses use unix time instead + #OP_CHECKLOCKTIMEVERIFY can only compare like with like, so we + #must use unix time as the transaction locktime + #Now ready to construct transaction log.info("Using a fee of : " + amount_to_str(fee_est) + ".") if amount != 0: log.info("Using a change value of: " + amount_to_str(changeval) + ".") txsigned = sign_tx(wallet_service, make_shuffled_tx( - list(utxos.keys()), outs, False, 2, compute_tx_locktime()), utxos) + list(utxos.keys()), outs, False, 2, tx_locktime), utxos) log.info("Got signed transaction:\n") log.info(pformat(txsigned)) tx = serialize(txsigned) log.info("In serialized form (for copy-paste):") log.info(tx) actual_amount = amount if amount != 0 else total_inputs_val - fee_est - log.info("Sends: " + amount_to_str(actual_amount) + " to address: " + destaddr) + log.info("Sends: " + amount_to_str(actual_amount) + " to destination: " + destination) if not answeryes: if not accept_callback: if input('Would you like to push to the network? (y/n):')[0] != 'y': log.info("You chose not to broadcast the transaction, quitting.") return False else: - accepted = accept_callback(pformat(txsigned), destaddr, actual_amount, + accepted = accept_callback(pformat(txsigned), destination, actual_amount, fee_est) if not accepted: return False diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 7385aa1..94122f7 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -7,6 +7,7 @@ import numbers import random from binascii import hexlify, unhexlify from datetime import datetime +from calendar import timegm from copy import deepcopy from mnemonic import Mnemonic as MnemonicParent from hashlib import sha256 @@ -20,7 +21,9 @@ from .blockchaininterface import INF_HEIGHT from .support import select_gradual, select_greedy, select_greediest, \ select from .cryptoengine import TYPE_P2PKH, TYPE_P2SH_P2WPKH,\ - TYPE_P2WPKH, ENGINES + TYPE_P2WPKH, TYPE_TIMELOCK_P2WSH, TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS,\ + TYPE_WATCHONLY_FIDELITY_BONDS, TYPE_WATCHONLY_TIMELOCK_P2WSH, TYPE_WATCHONLY_P2SH_P2WPKH,\ + ENGINES from .support import get_random_bytes from . import mn_encode, mn_decode import jmbitcoin as btc @@ -66,7 +69,7 @@ class Mnemonic(MnemonicParent): def detect_language(cls, code): return "english" -def estimate_tx_fee(ins, outs, txtype='p2pkh'): +def estimate_tx_fee(ins, outs, txtype='p2pkh', extra_bytes=0): '''Returns an estimate of the number of satoshis required for a transaction with the given number of inputs and outputs, based on information from the blockchain interface. @@ -79,13 +82,14 @@ def estimate_tx_fee(ins, outs, txtype='p2pkh'): raise ValueError("Estimated fee per kB greater than absurd value: " + \ str(absurd_fee) + ", quitting.") if txtype in ['p2pkh', 'p2shMofN']: - tx_estimated_bytes = btc.estimate_tx_size(ins, outs, txtype) + tx_estimated_bytes = btc.estimate_tx_size(ins, outs, txtype) + extra_bytes return int((tx_estimated_bytes * fee_per_kb)/Decimal(1000.0)) elif txtype in ['p2wpkh', 'p2sh-p2wpkh']: witness_estimate, non_witness_estimate = btc.estimate_tx_size( ins, outs, txtype) + non_witness_estimate += extra_bytes return int(int(( - non_witness_estimate + 0.25*witness_estimate)*fee_per_kb)/Decimal(1000.0)) + non_witness_estimate + 0.25*witness_estimate)*fee_per_kb)/Decimal(1000.0)) else: raise NotImplementedError("Txtype: " + txtype + " not implemented.") @@ -299,6 +303,9 @@ class BaseWallet(object): _ENGINE = None + ADDRESS_TYPE_EXTERNAL = 0 + ADDRESS_TYPE_INTERNAL = 1 + def __init__(self, storage, gap_limit=6, merge_algorithm_name=None, mixdepth=None): # to be defined by inheriting classes @@ -411,7 +418,8 @@ class BaseWallet(object): """ if self.TYPE == TYPE_P2PKH: return 'p2pkh' - elif self.TYPE == TYPE_P2SH_P2WPKH: + elif self.TYPE in (TYPE_P2SH_P2WPKH, + TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS): return 'p2sh-p2wpkh' elif self.TYPE == TYPE_P2WPKH: return 'p2wpkh' @@ -431,7 +439,7 @@ class BaseWallet(object): for index, (script, amount) in scripts.items(): assert amount > 0 path = self.script_to_path(script) - privkey, engine = self._get_priv_from_path(path) + privkey, engine = self._get_key_from_path(path) tx = btc.deserialize(engine.sign_transaction(tx, index, privkey, amount, **kwargs)) return tx @@ -443,12 +451,16 @@ class BaseWallet(object): """ script = self._ENGINE.address_to_script(addr) path = self.script_to_path(script) - privkey = self._get_priv_from_path(path)[0] + privkey = self._get_key_from_path(path)[0] return hexlify(privkey).decode('ascii') - def _get_addr_int_ext(self, internal, mixdepth): - script = self.get_internal_script(mixdepth) if internal else \ - self.get_external_script(mixdepth) + def _get_addr_int_ext(self, address_type, mixdepth): + if address_type == self.ADDRESS_TYPE_EXTERNAL: + script = self.get_external_script(mixdepth) + elif address_type == self.ADDRESS_TYPE_INTERNAL: + script = self.get_internal_script(mixdepth) + else: + assert 0 return self.script_to_addr(script) def get_external_addr(self, mixdepth): @@ -457,20 +469,20 @@ class BaseWallet(object): the wallet from other sources, or receiving payments or donations. JoinMarket will never generate these addresses for internal use. """ - return self._get_addr_int_ext(False, mixdepth) + return self._get_addr_int_ext(self.ADDRESS_TYPE_EXTERNAL, mixdepth) def get_internal_addr(self, mixdepth): """ Return an address for internal usage, as change addresses and when participating in transactions initiated by other parties. """ - return self._get_addr_int_ext(True, mixdepth) + return self._get_addr_int_ext(self.ADDRESS_TYPE_INTERNAL, mixdepth) def get_external_script(self, mixdepth): - return self.get_new_script(mixdepth, False) + return self.get_new_script(mixdepth, self.ADDRESS_TYPE_EXTERNAL) def get_internal_script(self, mixdepth): - return self.get_new_script(mixdepth, True) + return self.get_new_script(mixdepth, self.ADDRESS_TYPE_INTERNAL) @classmethod def addr_to_script(cls, addr): @@ -487,7 +499,7 @@ class BaseWallet(object): def script_to_addr(self, script): assert self.is_known_script(script) path = self.script_to_path(script) - engine = self._get_priv_from_path(path)[1] + engine = self._get_key_from_path(path)[1] return engine.script_to_address(script) def get_script_code(self, script): @@ -499,7 +511,7 @@ class BaseWallet(object): For non-segwit wallets, raises EngineError. """ path = self.script_to_path(script) - priv, engine = self._get_priv_from_path(path) + priv, engine = self._get_key_from_path(path) pub = engine.privkey_to_pubkey(priv) return engine.pubkey_to_script_code(pub) @@ -512,47 +524,47 @@ class BaseWallet(object): return cls._ENGINE.pubkey_has_script(pubkey, script) @deprecated - def get_key(self, mixdepth, internal, index): + def get_key(self, mixdepth, address_type, index): raise NotImplementedError() - def get_addr(self, mixdepth, internal, index): - script = self.get_script(mixdepth, internal, index) + def get_addr(self, mixdepth, address_type, index): + script = self.get_script(mixdepth, address_type, index) return self.script_to_addr(script) def get_address_from_path(self, path): script = self.get_script_from_path(path) return self.script_to_addr(script) - def get_new_addr(self, mixdepth, internal): + def get_new_addr(self, mixdepth, address_type): """ use get_external_addr/get_internal_addr """ - script = self.get_new_script(mixdepth, internal) + script = self.get_new_script(mixdepth, address_type) return self.script_to_addr(script) - def get_new_script(self, mixdepth, internal): + def get_new_script(self, mixdepth, address_type): raise NotImplementedError() - def get_wif(self, mixdepth, internal, index): - return self.get_wif_path(self.get_path(mixdepth, internal, index)) + def get_wif(self, mixdepth, address_type, index): + return self.get_wif_path(self.get_path(mixdepth, address_type, index)) def get_wif_path(self, path): - priv, engine = self._get_priv_from_path(path) + priv, engine = self._get_key_from_path(path) return engine.privkey_to_wif(priv) - def get_path(self, mixdepth=None, internal=None, index=None): + def get_path(self, mixdepth=None, address_type=None, index=None): raise NotImplementedError() def get_details(self, path): """ - Return mixdepth, internal, index for a given path + Return mixdepth, address_type, index for a given path args: path: wallet path returns: tuple (mixdepth, type, index) - type is one of 0, 1, 'imported' + type is one of 0, 1, 'imported', 2, 3 """ raise NotImplementedError() @@ -814,11 +826,11 @@ class BaseWallet(object): """ raise NotImplementedError() - def get_script(self, mixdepth, internal, index): - path = self.get_path(mixdepth, internal, index) + def get_script(self, mixdepth, address_type, index): + path = self.get_path(mixdepth, address_type, index) return self.get_script_from_path(path) - def _get_priv_from_path(self, path): + def _get_key_from_path(self, path): raise NotImplementedError() def get_path_repr(self, path): @@ -843,7 +855,7 @@ class BaseWallet(object): """ raise NotImplementedError() - def get_next_unused_index(self, mixdepth, internal): + def get_next_unused_index(self, mixdepth, address_type): """ Get the next index for public scripts/addresses not yet handed out. @@ -873,7 +885,7 @@ class BaseWallet(object): returns: signature as base64-encoded string """ - priv, engine = self._get_priv_from_path(path) + priv, engine = self._get_key_from_path(path) return engine.sign_message(priv, message) def get_wallet_name(self): @@ -952,13 +964,13 @@ class BaseWallet(object): assert script in self._script_map return self._script_map[script] - def set_next_index(self, mixdepth, internal, index, force=False): + def set_next_index(self, mixdepth, address_type, index, force=False): """ Set the next index to use when generating a new key pair. params: mixdepth: int - internal: 0/False or 1/True + address_type: 0 (external) or 1 (internal) index: int force: True if you know the wallet already knows all scripts up to (excluding) the given index @@ -969,28 +981,31 @@ class BaseWallet(object): def rewind_wallet_indices(self, used_indices, saved_indices): for md in used_indices: - for int_type in (0, 1): - index = max(used_indices[md][int_type], - saved_indices[md][int_type]) - self.set_next_index(md, int_type, index, force=True) + for address_type in range(min(len(used_indices[md]), len(saved_indices[md]))): + index = max(used_indices[md][address_type], + saved_indices[md][address_type]) + self.set_next_index(md, address_type, index, force=True) + + def _get_default_used_indices(self): + return {x: [0, 0] for x in range(self.max_mixdepth + 1)} def get_used_indices(self, addr_gen): """ Returns a dict of max used indices for each branch in the wallet, from the given addresses addr_gen, assuming that they are known to the wallet. """ - - indices = {x: [0, 0] for x in range(self.max_mixdepth + 1)} + indices = self._get_default_used_indices() for addr in addr_gen: if not self.is_known_addr(addr): continue - md, internal, index = self.get_details( + md, address_type, index = self.get_details( self.addr_to_path(addr)) - if internal not in (0, 1): - assert internal == 'imported' + if address_type not in (self.BIP32_EXT_ID, self.BIP32_INT_ID, + FidelityBondMixin.BIP32_TIMELOCK_ID, FidelityBondMixin.BIP32_BURN_ID): + assert address_type == 'imported' continue - indices[md][internal] = max(indices[md][internal], index + 1) + indices[md][address_type] = max(indices[md][address_type], index + 1) return indices @@ -1001,9 +1016,10 @@ class BaseWallet(object): cache.""" for md in used_indices: - for internal in (0, 1): - if used_indices[md][internal] >\ - max(self.get_next_unused_index(md, internal), 0): + for address_type in (self.ADDRESS_TYPE_EXTERNAL, + self.ADDRESS_TYPE_INTERNAL): + if used_indices[md][address_type] >\ + max(self.get_next_unused_index(md, address_type), 0): return False return True @@ -1094,7 +1110,7 @@ class ImportWalletMixin(object): #key_type = key_type_wif if key_type_wif is not None else self.TYPE engine = self._ENGINES[key_type] - if engine.privkey_to_script(privkey) in self._script_map: + if engine.key_to_script(privkey) in self._script_map: raise WalletError("Cannot import key, already in wallet: {}" "".format(wif)) @@ -1139,7 +1155,7 @@ class ImportWalletMixin(object): engine = self._ENGINES[key_type] path = (self._IMPORTED_ROOT_PATH, mixdepth, index) - self._script_map[engine.privkey_to_script(privkey)] = path + self._script_map[engine.key_to_script(privkey)] = path return path @@ -1150,9 +1166,9 @@ class ImportWalletMixin(object): assert len(path) == 3 return path[1] - def _get_priv_from_path(self, path): + def _get_key_from_path(self, path): if not self._is_imported_path(path): - return super(ImportWalletMixin, self)._get_priv_from_path(path) + return super(ImportWalletMixin, self)._get_key_from_path(path) assert len(path) == 3 md, i = path[1], path[2] @@ -1204,8 +1220,8 @@ class ImportWalletMixin(object): if not self._is_imported_path(path): return super(ImportWalletMixin, self).get_script_from_path(path) - priv, engine = self._get_priv_from_path(path) - return engine.privkey_to_script(priv) + priv, engine = self._get_key_from_path(path) + return engine.key_to_script(priv) class BIP39WalletMixin(object): @@ -1265,13 +1281,14 @@ class BIP32Wallet(BaseWallet): _STORAGE_ENTROPY_KEY = b'entropy' _STORAGE_INDEX_CACHE = b'index_cache' BIP32_MAX_PATH_LEVEL = 2**31 - BIP32_EXT_ID = 0 - BIP32_INT_ID = 1 + BIP32_EXT_ID = BaseWallet.ADDRESS_TYPE_EXTERNAL + BIP32_INT_ID = BaseWallet.ADDRESS_TYPE_INTERNAL ENTROPY_BYTES = 16 def __init__(self, storage, **kwargs): self._entropy = None - # {mixdepth: {type: index}} with type being 0/1 for [non]-internal + # {mixdepth: {type: index}} with type being 0/1 corresponding + # to external/internal addresses self._index_cache = None # path is a tuple of BIP32 levels, # m is the master key's fingerprint @@ -1287,9 +1304,7 @@ class BIP32Wallet(BaseWallet): # used to verify paths for sanity checking and for wallet id creation self._key_ident = b'' # otherwise get_bip32_* won't work - self._key_ident = sha256(sha256( - self.get_bip32_priv_export(0, 0).encode('ascii')).digest())\ - .digest()[:3] + self._key_ident = self._get_key_ident() self._populate_script_map() self.disable_new_scripts = False @@ -1331,11 +1346,16 @@ class BIP32Wallet(BaseWallet): self.max_mixdepth = max(0, 0, *self._index_cache.keys()) + def _get_key_ident(self): + return sha256(sha256( + self.get_bip32_priv_export(0, 0).encode('ascii')).digest())\ + .digest()[:3] + def _populate_script_map(self): for md in self._index_cache: - for int_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID): - for i in range(self._index_cache[md][int_type]): - path = self.get_path(md, int_type, i) + for address_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID): + for i in range(self._index_cache[md][address_type]): + path = self.get_path(md, address_type, i) script = self.get_script_from_path(path) self._script_map[script] = path @@ -1368,47 +1388,53 @@ class BIP32Wallet(BaseWallet): def _derive_bip32_master_key(cls, seed): return cls._ENGINE.derive_bip32_master_key(seed) + @classmethod + def _get_supported_address_types(cls): + return (cls.BIP32_EXT_ID, cls.BIP32_INT_ID) + def get_script_from_path(self, path): if not self._is_my_bip32_path(path): raise WalletError("unable to get script for unknown key path") - md, int_type, index = self.get_details(path) + md, address_type, index = self.get_details(path) if not 0 <= md <= self.max_mixdepth: raise WalletError("Mixdepth outside of wallet's range.") - assert int_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID) + assert address_type in self._get_supported_address_types() - current_index = self._index_cache[md][int_type] + current_index = self._index_cache[md][address_type] - if index == current_index: - return self.get_new_script_override_disable(md, int_type) + if index == current_index \ + and address_type != FidelityBondMixin.BIP32_TIMELOCK_ID: + #special case for timelocked addresses because for them the + #concept of a "next address" cant be used + return self.get_new_script_override_disable(md, address_type) - priv, engine = self._get_priv_from_path(path) - script = engine.privkey_to_script(priv) + priv, engine = self._get_key_from_path(path) + script = engine.key_to_script(priv) return script - def get_path(self, mixdepth=None, internal=None, index=None): + def get_path(self, mixdepth=None, address_type=None, index=None): if mixdepth is not None: assert isinstance(mixdepth, Integral) if not 0 <= mixdepth <= self.max_mixdepth: raise WalletError("Mixdepth outside of wallet's range.") - if internal is not None: + if address_type is not None: if mixdepth is None: - raise Exception("mixdepth must be set if internal is set") - int_type = self._get_internal_type(internal) + raise Exception("mixdepth must be set if address_type is set") if index is not None: assert isinstance(index, Integral) - if internal is None: - raise Exception("internal must be set if index is set") - assert index <= self._index_cache[mixdepth][int_type] + if address_type is None: + raise Exception("address_type must be set if index is set") + assert index <= self._index_cache[mixdepth][address_type] assert index < self.BIP32_MAX_PATH_LEVEL - return tuple(chain(self._get_bip32_export_path(mixdepth, internal), + return tuple(chain(self._get_bip32_export_path(mixdepth, address_type), (index,))) - return tuple(self._get_bip32_export_path(mixdepth, internal)) + return tuple(self._get_bip32_export_path(mixdepth, address_type)) def get_path_repr(self, path): path = list(path) @@ -1452,7 +1478,7 @@ class BIP32Wallet(BaseWallet): return path[len(self._get_bip32_base_path())] - def _get_priv_from_path(self, path): + def _get_key_from_path(self, path): if not self._is_my_bip32_path(path): raise WalletError("Invalid path, unknown root: {}".format(path)) @@ -1462,53 +1488,57 @@ class BIP32Wallet(BaseWallet): def _is_my_bip32_path(self, path): return path[0] == self._key_ident - def get_new_script(self, mixdepth, internal): + def get_new_script(self, mixdepth, address_type): if self.disable_new_scripts: raise RuntimeError("Obtaining new wallet addresses " + "disabled, due to nohistory mode") - return self.get_new_script_override_disable(mixdepth, internal) + return self.get_new_script_override_disable(mixdepth, address_type) - def get_new_script_override_disable(self, mixdepth, internal): + def get_new_script_override_disable(self, mixdepth, address_type): # This is called by get_script_from_path and calls back there. We need to # ensure all conditions match to avoid endless recursion. - int_type = self._get_internal_type(internal) - index = self._index_cache[mixdepth][int_type] - self._index_cache[mixdepth][int_type] += 1 - path = self.get_path(mixdepth, int_type, index) + index = self.get_index_cache_and_increment(mixdepth, address_type) + return self.get_script_and_update_map(mixdepth, address_type, index) + + def get_index_cache_and_increment(self, mixdepth, address_type): + index = self._index_cache[mixdepth][address_type] + self._index_cache[mixdepth][address_type] += 1 + return index + + def get_script_and_update_map(self, *args): + path = self.get_path(*args) script = self.get_script_from_path(path) self._script_map[script] = path return script - def get_script(self, mixdepth, internal, index): - path = self.get_path(mixdepth, internal, index) + def get_script(self, mixdepth, address_type, index): + path = self.get_path(mixdepth, address_type, index) return self.get_script_from_path(path) @deprecated - def get_key(self, mixdepth, internal, index): - int_type = self._get_internal_type(internal) - path = self.get_path(mixdepth, int_type, index) + def get_key(self, mixdepth, address_type, index): + path = self.get_path(mixdepth, address_type, index) priv = self._ENGINE.derive_bip32_privkey(self._master_key, path) return hexlify(priv).decode('ascii') - def get_bip32_priv_export(self, mixdepth=None, internal=None): - path = self._get_bip32_export_path(mixdepth, internal) + def get_bip32_priv_export(self, mixdepth=None, address_type=None): + path = self._get_bip32_export_path(mixdepth, address_type) return self._ENGINE.derive_bip32_priv_export(self._master_key, path) - def get_bip32_pub_export(self, mixdepth=None, internal=None): - path = self._get_bip32_export_path(mixdepth, internal) + def get_bip32_pub_export(self, mixdepth=None, address_type=None): + path = self._get_bip32_export_path(mixdepth, address_type) return self._ENGINE.derive_bip32_pub_export(self._master_key, path) - def _get_bip32_export_path(self, mixdepth=None, internal=None): + def _get_bip32_export_path(self, mixdepth=None, address_type=None): if mixdepth is None: - assert internal is None + assert address_type is None path = tuple() else: assert 0 <= mixdepth <= self.max_mixdepth - if internal is None: + if address_type is None: path = (self._get_bip32_mixdepth_path_level(mixdepth),) else: - int_type = self._get_internal_type(internal) - path = (self._get_bip32_mixdepth_path_level(mixdepth), int_type) + path = (self._get_bip32_mixdepth_path_level(mixdepth), address_type) return tuple(chain(self._get_bip32_base_path(), path)) @@ -1519,19 +1549,15 @@ class BIP32Wallet(BaseWallet): def _get_bip32_mixdepth_path_level(cls, mixdepth): return mixdepth - def _get_internal_type(self, is_internal): - return self.BIP32_INT_ID if is_internal else self.BIP32_EXT_ID - - def get_next_unused_index(self, mixdepth, internal): + def get_next_unused_index(self, mixdepth, address_type): assert 0 <= mixdepth <= self.max_mixdepth - int_type = self._get_internal_type(internal) - if self._index_cache[mixdepth][int_type] >= self.BIP32_MAX_PATH_LEVEL: + if self._index_cache[mixdepth][address_type] >= self.BIP32_MAX_PATH_LEVEL: # FIXME: theoretically this should work for up to # self.BIP32_MAX_PATH_LEVEL * 2, no? raise WalletError("All addresses used up, cannot generate new ones.") - return self._index_cache[mixdepth][int_type] + return self._index_cache[mixdepth][address_type] def get_mnemonic_words(self): return ' '.join(mn_encode(hexlify(self._entropy).decode('ascii'))), None @@ -1547,11 +1573,10 @@ class BIP32Wallet(BaseWallet): def get_wallet_id(self): return hexlify(self._key_ident).decode('ascii') - def set_next_index(self, mixdepth, internal, index, force=False): - int_type = self._get_internal_type(internal) - if not (force or index <= self._index_cache[mixdepth][int_type]): + def set_next_index(self, mixdepth, address_type, index, force=False): + if not (force or index <= self._index_cache[mixdepth][address_type]): raise Exception("cannot advance index without force=True") - self._index_cache[mixdepth][int_type] = index + self._index_cache[mixdepth][address_type] = index def get_details(self, path): if not self._is_my_bip32_path(path): @@ -1569,8 +1594,6 @@ class LegacyWallet(ImportWalletMixin, BIP32Wallet): def _get_bip32_base_path(self): return self._key_ident, 0 - - class BIP32PurposedWallet(BIP32Wallet): """ A class to encapsulate cases like BIP44, 49 and 84, all of which are derivatives @@ -1593,6 +1616,233 @@ class BIP32PurposedWallet(BIP32Wallet): return path[len(self._get_bip32_base_path())] - 2**31 +class FidelityBondMixin(object): + BIP32_TIMELOCK_ID = 2 + BIP32_BURN_ID = 3 + + """ + Explaination of time number + + incrementing time numbers (0, 1, 2, 3, 4... + will produce datetimes + suitable for timelocking (1st january, 1st april, 1st july .... + this greatly reduces the number of possible timelock values + and is helpful for recovery of funds because the wallet can search + the only addresses corresponding to timenumbers which are far fewer + + For example, if TIMENUMBER_UNIT = 2 (i.e. every time number is two months) + then there are 6 timelocks per year so just 600 possible + addresses per century per pubkey. Easily searchable when recovering a + wallet from seed phrase. Therefore the user doesn't need to store any + dates, the seed phrase is sufficent for recovery. + """ + #should be a factor of 12, the number of months in a year + TIMENUMBER_UNIT = 1 + + # all timelocks are 1st of the month at midnight + TIMELOCK_DAY_AND_SHORTER = (1, 0, 0, 0, 0) + TIMELOCK_EPOCH_YEAR = 2020 + TIMELOCK_EPOCH_MONTH = 1 #january + MONTHS_IN_YEAR = 12 + + TIMELOCK_ERA_YEARS = 30 + TIMENUMBERS_PER_PUBKEY = TIMELOCK_ERA_YEARS * MONTHS_IN_YEAR // TIMENUMBER_UNIT + + """ + As each pubkey corresponds to hundreds of addresses, to reduce load the + given gap limit will be reduced by this factor. Also these timelocked + addresses are never handed out to takers so there wont be a problem of + having many used addresses with no transactions on them. + """ + TIMELOCK_GAP_LIMIT_REDUCTION_FACTOR = 6 + + _TIMELOCK_ENGINE = ENGINES[TYPE_TIMELOCK_P2WSH] + + #only one mixdepth will have fidelity bonds in it + FIDELITY_BOND_MIXDEPTH = 0 + + MERKLE_BRANCH_UNAVAILABLE = b"mbu" + + _BURNER_OUTPUT_STORAGE_KEY = b"burner-out" + + _BIP32_PUBKEY_PREFIX = "fbonds-mpk-" + + @classmethod + def _time_number_to_timestamp(cls, timenumber): + """ + converts a time number to a unix timestamp + """ + if not 0 <= timenumber < cls.TIMENUMBERS_PER_PUBKEY: + raise ValueError() + year = cls.TIMELOCK_EPOCH_YEAR + (timenumber*cls.TIMENUMBER_UNIT) // cls.MONTHS_IN_YEAR + month = cls.TIMELOCK_EPOCH_MONTH + (timenumber*cls.TIMENUMBER_UNIT) % cls.MONTHS_IN_YEAR + return timegm(datetime(year, month, *cls.TIMELOCK_DAY_AND_SHORTER).timetuple()) + + @classmethod + def timestamp_to_time_number(cls, timestamp): + """ + converts a datetime object to a time number + """ + dt = datetime.utcfromtimestamp(timestamp) + if (dt.month - cls.TIMELOCK_EPOCH_MONTH) % cls.TIMENUMBER_UNIT != 0: + raise ValueError() + day_and_shorter_tuple = (dt.day, dt.hour, dt.minute, dt.second, dt.microsecond) + if day_and_shorter_tuple != cls.TIMELOCK_DAY_AND_SHORTER: + raise ValueError() + timenumber = (dt.year - cls.TIMELOCK_EPOCH_YEAR)*(cls.MONTHS_IN_YEAR // + cls.TIMENUMBER_UNIT) + ((dt.month - cls.TIMELOCK_EPOCH_MONTH) // cls.TIMENUMBER_UNIT) + if timenumber < 0 or timenumber > cls.TIMENUMBERS_PER_PUBKEY: + raise ValueError("datetime out of range") + return timenumber + + @classmethod + def is_timelocked_path(cls, path): + return len(path) > 4 and path[4] == cls.BIP32_TIMELOCK_ID + + def get_xpub_from_fidelity_bond_master_pub_key(cls, mpk): + if mpk.startswith(cls._BIP32_PUBKEY_PREFIX): + return mpk[len(cls._BIP32_PUBKEY_PREFIX):] + else: + return False + + def _get_key_ident(self): + first_path = self.get_path(0, 0) + priv, engine = self._get_key_from_path(first_path) + pub = engine.privkey_to_pubkey(priv) + return sha256(sha256(pub).digest()).digest()[:3] + + @classmethod + def get_xpub_from_fidelity_bond_master_pub_key(cls, mpk): + if mpk.startswith(cls._BIP32_PUBKEY_PREFIX): + return mpk[len(cls._BIP32_PUBKEY_PREFIX):] + else: + return False + + def _populate_script_map(self): + super(FidelityBondMixin, self)._populate_script_map() + for md in self._index_cache: + address_type = self.BIP32_TIMELOCK_ID + for i in range(self._index_cache[md][address_type]): + for timenumber in range(self.TIMENUMBERS_PER_PUBKEY): + path = self.get_path(md, address_type, i, timenumber) + script = self.get_script_from_path(path) + self._script_map[script] = path + + def add_utxo(self, txid, index, script, value, height=None): + super(FidelityBondMixin, self).add_utxo(txid, index, script, value, + height) + #dont use coin control freeze if wallet readonly + if self._storage.read_only: + return + path = self.script_to_path(script) + if not self.is_timelocked_path(path): + return + if datetime.utcfromtimestamp(path[-1]) > datetime.now(): + #freeze utxo if its timelock is in the future + self.disable_utxo(txid, index, disable=True) + + def get_bip32_pub_export(self, mixdepth=None, address_type=None): + bip32_pub = super(FidelityBondMixin, self).get_bip32_pub_export(mixdepth, address_type) + if address_type == None and mixdepth == self.FIDELITY_BOND_MIXDEPTH: + bip32_pub = self._BIP32_PUBKEY_PREFIX + bip32_pub + return bip32_pub + + @classmethod + def _get_supported_address_types(cls): + return (cls.BIP32_EXT_ID, cls.BIP32_INT_ID, cls.BIP32_TIMELOCK_ID, cls.BIP32_BURN_ID) + + def _get_key_from_path(self, path): + if self.is_timelocked_path(path): + key_path = path[:-1] + locktime = path[-1] + engine = self._TIMELOCK_ENGINE + privkey = engine.derive_bip32_privkey(self._master_key, key_path) + return (privkey, locktime), engine + else: + return super(FidelityBondMixin, self)._get_key_from_path(path) + + def get_path(self, mixdepth=None, address_type=None, index=None, timenumber=None): + if address_type == None or address_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID, + self.BIP32_BURN_ID) or index == None: + return super(FidelityBondMixin, self).get_path(mixdepth, address_type, index) + elif address_type == self.BIP32_TIMELOCK_ID: + assert timenumber != None + timestamp = self._time_number_to_timestamp(timenumber) + return tuple(chain(self._get_bip32_export_path(mixdepth, address_type), + (index, timestamp))) + else: + assert 0 + + """ + We define a new serialization of the bip32 path to include bip65 timelock addresses + Previously it was m/44'/1'/0'/3/0 + For a timelocked address it will be m/44'/1'/0'/3/0:13245432 + The timelock will be in unix format and added to the end with a colon ":" character + refering to the pubkey plus the timelock value which together are needed to create the address + """ + def get_path_repr(self, path): + if self.is_timelocked_path(path) and len(path) == 7: + return super(FidelityBondMixin, self).get_path_repr(path[:-1]) +\ + ":" + str(path[-1]) + else: + return super(FidelityBondMixin, self).get_path_repr(path) + + def path_repr_to_path(self, pathstr): + if pathstr.find(":") == -1: + return super(FidelityBondMixin, self).path_repr_to_path(pathstr) + else: + colon_chunks = pathstr.split(":") + if len(colon_chunks) != 2: + raise WalletError("Not a valid wallet timelock path: {}".format(pathstr)) + return tuple(chain( + super(FidelityBondMixin, self).path_repr_to_path(colon_chunks[0]), + (int(colon_chunks[1]),) + )) + + def get_details(self, path): + if self.is_timelocked_path(path): + return self._get_mixdepth_from_path(path), path[-3], path[-2] + else: + return super(FidelityBondMixin, self).get_details(path) + + def _get_default_used_indices(self): + return {x: [0, 0, 0, 0] for x in range(self.max_mixdepth + 1)} + + def get_script(self, mixdepth, address_type, index, timenumber=None): + path = self.get_path(mixdepth, address_type, index, timenumber) + return self.get_script_from_path(path) + + def get_addr(self, mixdepth, address_type, index, timenumber=None): + script = self.get_script(mixdepth, address_type, index, timenumber) + return self.script_to_addr(script) + + def add_burner_output(self, path, txhex, block_height, merkle_branch, + block_index, write=True): + """ + merkle_branch = None means it was unavailable because of pruning + """ + if self._BURNER_OUTPUT_STORAGE_KEY not in self._storage.data: + self._storage.data[self._BURNER_OUTPUT_STORAGE_KEY] = {} + path = path.encode() + txhex = unhexlify(txhex) + if not merkle_branch: + merkle_branch = self.MERKLE_BRANCH_UNAVAILABLE + self._storage.data[self._BURNER_OUTPUT_STORAGE_KEY][path] = [txhex, + block_height, merkle_branch, block_index] + if write: + self._storage.save() + + def get_burner_outputs(self): + """ + Result is a dict {path: [txhex, blockheight, merkleproof, blockindex]} + """ + return self._storage.data.get(self._BURNER_OUTPUT_STORAGE_KEY, {}) + + def set_burner_output_merkle_branch(self, path, merkle_branch): + path = path.encode() + self._storage.data[self._BURNER_OUTPUT_STORAGE_KEY][path][2] = \ + merkle_branch + class BIP49Wallet(BIP32PurposedWallet): _PURPOSE = 2**31 + 49 _ENGINE = ENGINES[TYPE_P2SH_P2WPKH] @@ -1607,8 +1857,33 @@ class SegwitLegacyWallet(ImportWalletMixin, BIP39WalletMixin, BIP49Wallet): class SegwitWallet(ImportWalletMixin, BIP39WalletMixin, BIP84Wallet): TYPE = TYPE_P2WPKH +class SegwitLegacyWalletFidelityBonds(FidelityBondMixin, SegwitLegacyWallet): + TYPE = TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS + + +class FidelityBondWatchonlyWallet(FidelityBondMixin, BIP49Wallet): + TYPE = TYPE_WATCHONLY_FIDELITY_BONDS + _ENGINE = ENGINES[TYPE_WATCHONLY_P2SH_P2WPKH] + _TIMELOCK_ENGINE = ENGINES[TYPE_WATCHONLY_TIMELOCK_P2WSH] + + @classmethod + def _verify_entropy(cls, ent): + return ent[1:4] == b"pub" + + @classmethod + def _derive_bip32_master_key(cls, master_entropy): + return btc.bip32_deserialize(master_entropy.decode()) + + def _get_bip32_export_path(self, mixdepth=None, address_type=None): + path = super(FidelityBondWatchonlyWallet, self)._get_bip32_export_path( + mixdepth, address_type) + return path + + WALLET_IMPLEMENTATIONS = { LegacyWallet.TYPE: LegacyWallet, SegwitLegacyWallet.TYPE: SegwitLegacyWallet, - SegwitWallet.TYPE: SegwitWallet + SegwitWallet.TYPE: SegwitWallet, + SegwitLegacyWalletFidelityBonds.TYPE: SegwitLegacyWalletFidelityBonds, + FidelityBondWatchonlyWallet.TYPE: FidelityBondWatchonlyWallet } diff --git a/jmclient/jmclient/wallet_service.py b/jmclient/jmclient/wallet_service.py index 459bd90..8d80643 100644 --- a/jmclient/jmclient/wallet_service.py +++ b/jmclient/jmclient/wallet_service.py @@ -5,6 +5,7 @@ import time import ast import binascii import sys +import itertools from decimal import Decimal from copy import deepcopy from twisted.internet import reactor @@ -15,7 +16,9 @@ from jmclient.configure import jm_single, get_log from jmclient.output import fmt_tx_data from jmclient.blockchaininterface import (INF_HEIGHT, BitcoinCoreInterface, BitcoinCoreNoHistoryInterface) +from jmclient.wallet import FidelityBondMixin from jmbase.support import jmprint, EXIT_SUCCESS +import jmbitcoin as btc """Wallet service The purpose of this independent service is to allow @@ -516,6 +519,19 @@ class WalletService(Service): # index: self.bci.import_addresses(self.collect_addresses_gap(), self.get_wallet_name(), self.restart_callback) + + if isinstance(self.wallet, FidelityBondMixin): + mixdepth = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_BURN_ID + + burner_outputs = self.wallet.get_burner_outputs() + max_index = 0 + for path_repr in burner_outputs: + index = self.wallet.path_repr_to_path(path_repr.decode())[-1] + max_index = max(index+1, max_index) + self.wallet.set_next_index(mixdepth, address_type, max_index, + force=True) + self.synced = True def display_rescan_message_and_system_exit(self, restart_cb): @@ -536,6 +552,48 @@ class WalletService(Service): jmprint(restart_msg, "important") sys.exit(EXIT_SUCCESS) + def sync_burner_outputs(self, burner_txes): + mixdepth = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_BURN_ID + self.wallet.set_next_index(mixdepth, address_type, self.wallet.gap_limit, + force=True) + highest_used_index = 0 + known_burner_outputs = self.wallet.get_burner_outputs() + + index = -1 + while index - highest_used_index < self.wallet.gap_limit: + index += 1 + self.wallet.set_next_index(mixdepth, address_type, index, force=True) + path = self.wallet.get_path(mixdepth, address_type, index) + path_privkey, engine = self.wallet._get_key_from_path(path) + path_pubkey = engine.privkey_to_pubkey(path_privkey) + path_pubkeyhash = btc.bin_hash160(path_pubkey) + for burner_tx in burner_txes: + burner_pubkeyhash, gettx = burner_tx + if burner_pubkeyhash != path_pubkeyhash: + continue + highest_used_index = index + path_repr = self.wallet.get_path_repr(path) + if path_repr.encode() in known_burner_outputs: + continue + txid = gettx["txid"] + jlog.info("Found a burner transaction txid=" + txid + " path = " + + path_repr) + try: + merkle_branch = self.bci.get_tx_merkle_branch(txid, gettx["blockhash"]) + except ValueError as e: + jlog.warning(repr(e)) + jlog.warning("Merkle branch likely not available, use " + + "wallet-tool `addtxoutproof`") + merkle_branch = None + block_height = self.bci.rpc("getblockheader", [gettx["blockhash"]])["height"] + if merkle_branch: + assert self.bci.verify_tx_merkle_branch(txid, block_height, merkle_branch) + self.wallet.add_burner_output(path_repr, gettx["hex"], block_height, + merkle_branch, gettx["blockindex"]) + + self.wallet.set_next_index(mixdepth, address_type, highest_used_index + 1) + def sync_addresses(self): """ Triggered by use of --recoversync option in scripts, attempts a full scan of the blockchain without assuming @@ -552,9 +610,35 @@ class WalletService(Service): self.display_rescan_message_and_system_exit(self.restart_callback) return - used_addresses_gen = (tx['address'] - for tx in self.bci._yield_transactions(wallet_name) - if tx['category'] == 'receive') + if isinstance(self.wallet, FidelityBondMixin): + tx_receive = [] + burner_txes = [] + for tx in self.bci._yield_transactions(wallet_name): + if tx['category'] == 'receive': + tx_receive.append(tx) + elif tx["category"] == "send": + gettx = self.bci.get_transaction(tx["txid"]) + txd = self.bci.get_deser_from_gettransaction(gettx) + if len(txd["outs"]) > 1: + continue + #must be mined into a block to sync + #otherwise there's no merkleproof or block index + if gettx["confirmations"] < 1: + continue + script = binascii.unhexlify(txd["outs"][0]["script"]) + if script[0] != 0x6a: #OP_RETURN + continue + pubkeyhash = script[2:] + burner_txes.append((pubkeyhash, gettx)) + + self.sync_burner_outputs(burner_txes) + used_addresses_gen = (tx["address"] for tx in tx_receive) + else: + #not fidelity bond wallet, significantly faster sync + used_addresses_gen = (tx['address'] + for tx in self.bci._yield_transactions(wallet_name) + if tx['category'] == 'receive') + used_indices = self.get_used_indices(used_addresses_gen) jlog.debug("got used indices: {}".format(used_indices)) gap_limit_used = not self.check_gap_indices(used_indices) @@ -721,20 +805,36 @@ class WalletService(Service): for md in range(self.max_mixdepth + 1): saved_indices[md] = [0, 0] - for internal in (0, 1): - next_unused = self.get_next_unused_index(md, internal) + for address_type in (0, 1): + next_unused = self.get_next_unused_index(md, address_type) for index in range(next_unused): - addresses.add(self.get_addr(md, internal, index)) + addresses.add(self.get_addr(md, address_type, index)) for index in range(self.gap_limit): - addresses.add(self.get_new_addr(md, internal)) + addresses.add(self.get_new_addr(md, address_type)) # reset the indices to the value we had before the # new address calls: - self.set_next_index(md, internal, next_unused) - saved_indices[md][internal] = next_unused + self.set_next_index(md, address_type, next_unused) + saved_indices[md][address_type] = next_unused # include any imported addresses for path in self.yield_imported_paths(md): addresses.add(self.get_address_from_path(path)) + if isinstance(self.wallet, FidelityBondMixin): + md = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + saved_indices[md] += [0] + next_unused = self.get_next_unused_index(md, address_type) + for index in range(next_unused): + for timenumber in range(FidelityBondMixin.TIMENUMBERS_PER_PUBKEY): + addresses.add(self.get_addr(md, address_type, index, timenumber)) + for index in range(self.gap_limit // FidelityBondMixin.TIMELOCK_GAP_LIMIT_REDUCTION_FACTOR): + index += next_unused + assert self.wallet.get_index_cache_and_increment(md, address_type) == index + for timenumber in range(FidelityBondMixin.TIMENUMBERS_PER_PUBKEY): + self.wallet.get_script_and_update_map(md, address_type, index, timenumber) + addresses.add(self.get_addr(md, address_type, index, timenumber)) + self.wallet.set_next_index(md, address_type, next_unused) + return addresses, saved_indices def collect_addresses_gap(self, gap_limit=None): @@ -742,11 +842,22 @@ class WalletService(Service): addresses = set() for md in range(self.max_mixdepth + 1): - for internal in (True, False): - old_next = self.get_next_unused_index(md, internal) + for address_type in (1, 0): + old_next = self.get_next_unused_index(md, address_type) for index in range(gap_limit): - addresses.add(self.get_new_addr(md, internal)) - self.set_next_index(md, internal, old_next) + addresses.add(self.get_new_addr(md, address_type)) + self.set_next_index(md, address_type, old_next) + + if isinstance(self.wallet, FidelityBondMixin): + md = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + old_next = self.get_next_unused_index(md, address_type) + for ii in range(gap_limit // FidelityBondMixin.TIMELOCK_GAP_LIMIT_REDUCTION_FACTOR): + index = self.wallet.get_index_cache_and_increment(md, address_type) + for timenumber in range(FidelityBondMixin.TIMENUMBERS_PER_PUBKEY): + self.wallet.get_script_and_update_map(md, address_type, index, timenumber) + addresses.add(self.get_addr(md, address_type, index, timenumber)) + self.set_next_index(md, address_type, old_next) return addresses diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index 50869d2..27df19c 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -5,6 +5,7 @@ import sys import sqlite3 import binascii from datetime import datetime +from calendar import timegm from optparse import OptionParser from numbers import Integral from collections import Counter @@ -12,12 +13,13 @@ from itertools import islice from jmclient import (get_network, WALLET_IMPLEMENTATIONS, Storage, podle, jm_single, BitcoinCoreInterface, WalletError, VolatileStorage, StoragePasswordError, is_segwit_mode, SegwitLegacyWallet, - LegacyWallet, SegwitWallet, is_native_segwit_mode, load_program_config, - add_base_options, check_regtest) + LegacyWallet, SegwitWallet, FidelityBondMixin, FidelityBondWatchonlyWallet, + is_native_segwit_mode, load_program_config, add_base_options, check_regtest) from jmclient.wallet_service import WalletService from jmbase.support import get_password, jmprint, EXIT_FAILURE, EXIT_ARGERROR -from .cryptoengine import TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH +from .cryptoengine import TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, \ + TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS from .output import fmt_utxo import jmbitcoin as btc @@ -42,8 +44,12 @@ def get_wallettool_parser(): '(dumpprivkey) Export a single private key, specify an hd wallet path\n' '(signmessage) Sign a message with the private key from an address in \n' 'the wallet. Use with -H and specify an HD wallet path for the address.\n' - '(freeze) Freeze or un-freeze a specific utxo. Specify mixdepth with -m.') - parser = OptionParser(usage='usage: %prog [options] [wallet file] [method]', + '(freeze) Freeze or un-freeze a specific utxo. Specify mixdepth with -m.\n' + '(gettimelockaddress) Obtain a timelocked address. Argument is locktime value as yyyy-mm. For example `2021-03`\n' + '(addtxoutproof) Add a tx out proof as metadata to a burner transaction. Specify path with ' + '-H and proof which is output of Bitcoin Core\'s RPC call gettxoutproof\n' + '(createwatchonly) Create a watch-only fidelity bond wallet') + parser = OptionParser(usage='usage: %prog [options] [wallet file] [method] [args..]', description=description) add_base_options(parser) parser.add_option('-p', @@ -162,13 +168,15 @@ class WalletViewBase(object): return "{0:.08f}".format(self.get_balance(include_unconf)) class WalletViewEntry(WalletViewBase): - def __init__(self, wallet_path_repr, account, forchange, aindex, addr, amounts, + def __init__(self, wallet_path_repr, account, address_type, aindex, addr, amounts, used = 'new', serclass=str, priv=None, custom_separator=None): super(WalletViewEntry, self).__init__(wallet_path_repr, serclass=serclass, custom_separator=custom_separator) self.account = account - assert forchange in [0, 1, -1] - self.forchange =forchange + assert address_type in [SegwitWallet.BIP32_EXT_ID, + SegwitWallet.BIP32_INT_ID, -1, FidelityBondMixin.BIP32_TIMELOCK_ID, + FidelityBondMixin.BIP32_BURN_ID] + self.address_type = address_type assert isinstance(aindex, Integral) assert aindex >= 0 self.aindex = aindex @@ -212,15 +220,23 @@ class WalletViewEntry(WalletViewBase): ed += self.separator + self.serclass(self.private_key) return self.serclass(ed) +class WalletViewEntryBurnOutput(WalletViewEntry): + # balance in burn outputs shouldnt be counted + # towards the total balance + def get_balance(self, include_unconf=True): + return 0 + class WalletViewBranch(WalletViewBase): - def __init__(self, wallet_path_repr, account, forchange, branchentries=None, + def __init__(self, wallet_path_repr, account, address_type, branchentries=None, xpub=None, serclass=str, custom_separator=None): super(WalletViewBranch, self).__init__(wallet_path_repr, children=branchentries, serclass=serclass, custom_separator=custom_separator) self.account = account - assert forchange in [0, 1, -1] - self.forchange = forchange + assert address_type in [SegwitWallet.BIP32_EXT_ID, + SegwitWallet.BIP32_INT_ID, -1, FidelityBondMixin.BIP32_TIMELOCK_ID, + FidelityBondMixin.BIP32_BURN_ID] + self.address_type = address_type if xpub: assert xpub.startswith('xpub') or xpub.startswith('tpub') self.xpub = xpub if xpub else "" @@ -238,8 +254,8 @@ class WalletViewBranch(WalletViewBase): return self.serclass(entryseparator.join(lines)) def serialize_branch_header(self): - start = "external addresses" if self.forchange == 0 else "internal addresses" - if self.forchange == -1: + start = "external addresses" if self.address_type == 0 else "internal addresses" + if self.address_type == -1: start = "Imported keys" return self.serclass(self.separator.join([start, self.wallet_path_repr, self.xpub])) @@ -254,7 +270,7 @@ class WalletViewAccount(WalletViewBase): self.account_name = account_name self.xpub = xpub if branches: - assert len(branches) in [2, 3] #3 if imported keys + assert len(branches) in [2, 3, 4] #3 if imported keys, 4 if fidelity bonds assert all([isinstance(x, WalletViewBranch) for x in branches]) self.branches = branches @@ -418,33 +434,105 @@ def wallet_display(wallet_service, showprivkey, displayall=False, utxos = wallet_service.get_utxos_by_mixdepth(include_disabled=True, hexfmt=False) for m in range(wallet_service.mixdepth + 1): branchlist = [] - for forchange in [0, 1]: + for address_type in [0, 1]: entrylist = [] - if forchange == 0: + if address_type == 0: # users would only want to hand out the xpub for externals - xpub_key = wallet_service.get_bip32_pub_export(m, forchange) + xpub_key = wallet_service.get_bip32_pub_export(m, address_type) else: xpub_key = "" - unused_index = wallet_service.get_next_unused_index(m, forchange) + unused_index = wallet_service.get_next_unused_index(m, address_type) for k in range(unused_index + wallet_service.gap_limit): - path = wallet_service.get_path(m, forchange, k) + path = wallet_service.get_path(m, address_type, k) addr = wallet_service.get_address_from_path(path) balance, used = get_addr_status( - path, utxos[m], k >= unused_index, forchange) + path, utxos[m], k >= unused_index, address_type) if showprivkey: privkey = wallet_service.get_wif_path(path) else: privkey = '' if (displayall or balance > 0 or - (used == 'new' and forchange == 0)): + (used == 'new' and address_type == 0)): entrylist.append(WalletViewEntry( - wallet_service.get_path_repr(path), m, forchange, k, addr, + wallet_service.get_path_repr(path), m, address_type, k, addr, [balance, balance], priv=privkey, used=used)) - wallet_service.set_next_index(m, forchange, unused_index) - path = wallet_service.get_path_repr(wallet_service.get_path(m, forchange)) - branchlist.append(WalletViewBranch(path, m, forchange, entrylist, + wallet_service.set_next_index(m, address_type, unused_index) + path = wallet_service.get_path_repr(wallet_service.get_path(m, address_type)) + branchlist.append(WalletViewBranch(path, m, address_type, entrylist, xpub=xpub_key)) + + if m == FidelityBondMixin.FIDELITY_BOND_MIXDEPTH and \ + isinstance(wallet_service.wallet, FidelityBondMixin): + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + unused_index = wallet_service.get_next_unused_index(m, address_type) + timelocked_gaplimit = (wallet_service.wallet.gap_limit + // FidelityBondMixin.TIMELOCK_GAP_LIMIT_REDUCTION_FACTOR) + entrylist = [] + for k in range(unused_index + timelocked_gaplimit): + for timenumber in range(FidelityBondMixin.TIMENUMBERS_PER_PUBKEY): + path = wallet_service.get_path(m, address_type, k, timenumber) + addr = wallet_service.get_address_from_path(path) + timelock = datetime.utcfromtimestamp(path[-1]) + + balance = sum([utxodata["value"] for utxo, utxodata in + iteritems(utxos[m]) if path == utxodata["path"]]) + status = timelock.strftime("%Y-%m-%d") + " [" + ( + "LOCKED" if datetime.now() < timelock else "UNLOCKED") + "]" + privkey = "" + if showprivkey: + privkey = wallet_service.get_wif_path(path) + if displayall or balance > 0: + entrylist.append(WalletViewEntry( + wallet_service.get_path_repr(path), m, address_type, k, + addr, [balance, balance], priv=privkey, used=status)) + xpub_key = wallet_service.get_bip32_pub_export(m, address_type) + path = wallet_service.get_path_repr(wallet_service.get_path(m, address_type)) + branchlist.append(WalletViewBranch(path, m, address_type, entrylist, + xpub=xpub_key)) + + entrylist = [] + address_type = FidelityBondMixin.BIP32_BURN_ID + unused_index = wallet_service.get_next_unused_index(m, address_type) + burner_outputs = wallet_service.wallet.get_burner_outputs() + wallet_service.set_next_index(m, address_type, unused_index + + wallet_service.wallet.gap_limit, force=True) + for k in range(unused_index + wallet_service.wallet.gap_limit): + path = wallet_service.get_path(m, address_type, k) + path_repr = wallet_service.get_path_repr(path) + path_repr_b = path_repr.encode() + + privkey, engine = wallet_service._get_key_from_path(path) + pubkey = engine.privkey_to_pubkey(privkey) + pubkeyhash = btc.bin_hash160(pubkey) + output = "BURN-" + binascii.hexlify(pubkeyhash).decode() + + balance = 0 + status = "no transaction" + if path_repr_b in burner_outputs: + txhex, blockheight, merkle_branch, blockindex = burner_outputs[path_repr_b] + txhex = binascii.hexlify(txhex).decode() + txd = btc.deserialize(txhex) + assert len(txd["outs"]) == 1 + balance = txd["outs"][0]["value"] + script = binascii.unhexlify(txd["outs"][0]["script"]) + assert script[0] == 0x6a #OP_RETURN + tx_pubkeyhash = script[2:] + assert tx_pubkeyhash == pubkeyhash + status = btc.txhash(txhex) + (" [NO MERKLE PROOF]" if + merkle_branch == FidelityBondMixin.MERKLE_BRANCH_UNAVAILABLE else "") + privkey = (wallet_service.get_wif_path(path) if showprivkey else "") + if displayall or balance > 0: + entrylist.append(WalletViewEntryBurnOutput(path_repr, m, + address_type, k, output, [balance, balance], + priv=privkey, used=status)) + wallet_service.set_next_index(m, address_type, unused_index) + + xpub_key = wallet_service.get_bip32_pub_export(m, address_type) + path = wallet_service.get_path_repr(wallet_service.get_path(m, address_type)) + branchlist.append(WalletViewBranch(path, m, address_type, entrylist, + xpub=xpub_key)) + ipb = get_imported_privkey_branch(wallet_service, m, showprivkey) if ipb: branchlist.append(ipb) @@ -468,8 +556,8 @@ def cli_get_wallet_passphrase_check(): return False return password -def cli_get_wallet_file_name(): - return input('Input wallet file name (default: wallet.jmdat): ') +def cli_get_wallet_file_name(defaultname="wallet.jmdat"): + return input('Input wallet file name (default: ' + defaultname + '): ') def cli_display_user_words(words, mnemonic_extension): text = 'Write down this wallet recovery mnemonic\n\n' + words +'\n' @@ -498,11 +586,19 @@ def cli_get_mnemonic_extension(): "info") return input("Enter mnemonic extension: ") +def cli_do_support_fidelity_bonds(): + uin = input("Would you like this wallet to support fidelity bonds? " + "write 'n' if you don't know what this is (y/n): ") + if len(uin) == 0 or uin[0] != 'y': + jmprint("Not supporting fidelity bonds", "info") + return False + else: + return True def wallet_generate_recover_bip39(method, walletspath, default_wallet_name, display_seed_callback, enter_seed_callback, enter_wallet_password_callback, enter_wallet_file_name_callback, enter_if_use_seed_extension, - enter_seed_extension_callback, mixdepth=DEFAULT_MIXDEPTH): + enter_seed_extension_callback, enter_do_support_fidelity_bonds, mixdepth=DEFAULT_MIXDEPTH): entropy = None mnemonic_extension = None if method == "generate": @@ -536,7 +632,13 @@ def wallet_generate_recover_bip39(method, walletspath, default_wallet_name, wallet_name = default_wallet_name wallet_path = os.path.join(walletspath, wallet_name) - wallet = create_wallet(wallet_path, password, mixdepth, + # disable creating fidelity bond wallets for now until the + # rest of the fidelity bond feature is created + #support_fidelity_bonds = enter_do_support_fidelity_bonds() + support_fidelity_bonds = False + wallet_cls = get_wallet_cls(get_configured_wallet_type(support_fidelity_bonds)) + + wallet = create_wallet(wallet_path, password, mixdepth, wallet_cls, entropy=entropy, entropy_extension=mnemonic_extension) mnemonic, mnext = wallet.get_mnemonic_words() @@ -554,7 +656,7 @@ def wallet_generate_recover(method, walletspath, default_wallet_name, cli_display_user_words, cli_user_mnemonic_entry, cli_get_wallet_passphrase_check, cli_get_wallet_file_name, cli_do_use_mnemonic_extension, cli_get_mnemonic_extension, - mixdepth=mixdepth) + cli_do_support_fidelity_bonds, mixdepth=mixdepth) entropy = None if method == 'recover': @@ -1040,29 +1142,99 @@ def wallet_freezeutxo(wallet_service, md, display_callback=None, info_callback=N .format(fmt_utxo((txid, index)))) return "Done" -def get_wallet_type(): + + +def wallet_gettimelockaddress(wallet, locktime_string): + if not isinstance(wallet, FidelityBondMixin): + jmprint("Error: not a fidelity bond wallet", "error") + return "" + + m = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + index = wallet.get_next_unused_index(m, address_type) + lock_datetime = datetime.strptime(locktime_string, "%Y-%m") + timenumber = FidelityBondMixin.timestamp_to_time_number(timegm( + lock_datetime.timetuple())) + + path = wallet.get_path(m, address_type, index, timenumber) + jmprint("path = " + wallet.get_path_repr(path), "info") + jmprint("Coins sent to this address will be not be spendable until " + + lock_datetime.strftime("%B %Y") + ". Full date: " + + str(lock_datetime)) + addr = wallet.get_address_from_path(path) + return addr + +def wallet_addtxoutproof(wallet_service, hdpath, txoutproof): + if not isinstance(wallet_service.wallet, FidelityBondMixin): + jmprint("Error: not a fidelity bond wallet", "error") + return "" + path = hdpath.encode() + if path not in wallet_service.wallet.get_burner_outputs(): + jmprint("Error: unknown burner transaction with on that path", "error") + return "" + txhex, block_height, old_merkle_branch, block_index = \ + wallet_service.wallet.get_burner_outputs()[path] + new_merkle_branch = jm_single().bc_interface.core_proof_to_merkle_branch(txoutproof) + txhex = binascii.hexlify(txhex).decode() + txid = btc.txhash(txhex) + if not jm_single().bc_interface.verify_tx_merkle_branch(txid, block_height, + new_merkle_branch): + jmprint("Error: tx out proof invalid", "error") + return "" + wallet_service.wallet.add_burner_output(hdpath, txhex, block_height, + new_merkle_branch, block_index) + return "Done" + +def wallet_createwatchonly(wallet_root_path, master_pub_key): + + wallet_name = cli_get_wallet_file_name(defaultname="watchonly.jmdat") + if not wallet_name: + DEFAULT_WATCHONLY_WALLET_NAME = "watchonly.jmdat" + wallet_name = DEFAULT_WATCHONLY_WALLET_NAME + + wallet_path = os.path.join(wallet_root_path, wallet_name) + + password = cli_get_wallet_passphrase_check() + if not password: + return "" + + entropy = FidelityBondMixin.get_xpub_from_fidelity_bond_master_pub_key(master_pub_key) + if not entropy: + jmprint("Error with provided master pub key", "error") + return "" + entropy = entropy.encode() + + wallet = create_wallet(wallet_path, password, + max_mixdepth=FidelityBondMixin.FIDELITY_BOND_MIXDEPTH, + wallet_cls=FidelityBondWatchonlyWallet, entropy=entropy) + return "Done" + +def get_configured_wallet_type(support_fidelity_bonds): + configured_type = TYPE_P2PKH if is_segwit_mode(): if is_native_segwit_mode(): - return TYPE_P2WPKH - return TYPE_P2SH_P2WPKH - return TYPE_P2PKH + configured_type = TYPE_P2WPKH + else: + configured_type = TYPE_P2SH_P2WPKH + if not support_fidelity_bonds: + return configured_type -def get_wallet_cls(wtype=None): - if wtype is None: - wtype = get_wallet_type() + if configured_type == TYPE_P2SH_P2WPKH: + return TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS + else: + raise ValueError("Fidelity bonds not supported with the configured " + "options of segwit and native. Edit joinmarket.cfg") +def get_wallet_cls(wtype): cls = WALLET_IMPLEMENTATIONS.get(wtype) - if not cls: raise WalletError("No wallet implementation found for type {}." "".format(wtype)) return cls - -def create_wallet(path, password, max_mixdepth, wallet_cls=None, **kwargs): +def create_wallet(path, password, max_mixdepth, wallet_cls, **kwargs): storage = Storage(path, password, create=True) - wallet_cls = wallet_cls or get_wallet_cls() wallet_cls.initialize(storage, get_network(), max_mixdepth=max_mixdepth, **kwargs) storage.save() @@ -1198,13 +1370,14 @@ def wallet_tool_main(wallet_root_path): check_regtest(blockchain_start=False) # full path to the wallets/ subdirectory in the user data area: wallet_root_path = os.path.join(jm_single().datadir, wallet_root_path) - noseed_methods = ['generate', 'recover'] + noseed_methods = ['generate', 'recover', 'createwatchonly'] methods = ['display', 'displayall', 'summary', 'showseed', 'importprivkey', - 'history', 'showutxos', 'freeze'] + 'history', 'showutxos', 'freeze', 'gettimelockaddress', 'addtxoutproof'] methods.extend(noseed_methods) noscan_methods = ['showseed', 'importprivkey', 'dumpprivkey', 'signmessage'] readonly_methods = ['display', 'displayall', 'summary', 'showseed', - 'history', 'showutxos', 'dumpprivkey', 'signmessage'] + 'history', 'showutxos', 'dumpprivkey', 'signmessage', + 'gettimelockaddress'] if len(args) < 1: parser.error('Needs a wallet file or method') @@ -1224,6 +1397,11 @@ def wallet_tool_main(wallet_root_path): method = ('display' if len(args) == 1 else args[1].lower()) read_only = method in readonly_methods + #special case needed for fidelity bond burner outputs + #maybe theres a better way to do this + if options.recoversync: + read_only = False + wallet = open_test_wallet_maybe( wallet_path, seed, options.mixdepth, read_only=read_only, wallet_password_stdin=options.wallet_password_stdin, gap_limit=options.gaplimit) @@ -1278,9 +1456,28 @@ def wallet_tool_main(wallet_root_path): map_key_type(options.key_type)) return "Key import completed." elif method == "signmessage": + if len(args) < 3: + jmprint('Must provide message to sign', "error") + sys.exit(EXIT_ARGERROR) return wallet_signmessage(wallet_service, options.hd_path, args[2]) elif method == "freeze": return wallet_freezeutxo(wallet_service, options.mixdepth) + elif method == "gettimelockaddress": + if len(args) < 3: + jmprint('Must have locktime value yyyy-mm. For example 2021-03', "error") + sys.exit(EXIT_ARGERROR) + return wallet_gettimelockaddress(wallet_service.wallet, args[2]) + elif method == "addtxoutproof": + if len(args) < 3: + jmprint('Must have txout proof, which is the output of Bitcoin ' + + 'Core\'s RPC call gettxoutproof', "error") + sys.exit(EXIT_ARGERROR) + return wallet_addtxoutproof(wallet_service, options.hd_path, args[2]) + elif method == "createwatchonly": + if len(args) < 2: + jmprint("args: [master public key]", "error") + sys.exit(EXIT_ARGERROR) + return wallet_createwatchonly(wallet_root_path, args[1]) else: parser.error("Unknown wallet-tool method: " + method) sys.exit(EXIT_ARGERROR) @@ -1296,15 +1493,15 @@ if __name__ == "__main__": acctlist = [] for a in accounts: branches = [] - for forchange in range(2): + for address_type in range(2): entries = [] for i in range(4): - entries.append(WalletViewEntry(rootpath, a, forchange, + entries.append(WalletViewEntry(rootpath, a, address_type, i, "DUMMYADDRESS"+str(i+a), [i*10000000, i*10000000])) branches.append(WalletViewBranch(rootpath, - a, forchange, branchentries=entries, - xpub="xpubDUMMYXPUB"+str(a+forchange))) + a, address_type, branchentries=entries, + xpub="xpubDUMMYXPUB"+str(a+address_type))) acctlist.append(WalletViewAccount(rootpath, a, branches=branches)) wallet = WalletView(rootpath + "/" + str(walletbranch), accounts=acctlist) diff --git a/jmclient/test/commontest.py b/jmclient/test/commontest.py index 463c97b..55395e0 100644 --- a/jmclient/test/commontest.py +++ b/jmclient/test/commontest.py @@ -209,3 +209,13 @@ def interact(process, inputs, expected): for i, inp in enumerate(inputs): process.expect(expected[i]) process.sendline(inp) + +def ensure_bip65_activated(): + #on regtest bip65 activates on height 1351 + #https://github.com/bitcoin/bitcoin/blob/1d1f8bbf57118e01904448108a104e20f50d2544/src/chainparams.cpp#L262 + BIP65Height = 1351 + current_height = jm_single().bc_interface.rpc("getblockchaininfo", [])["blocks"] + until_bip65_activation = BIP65Height - current_height + 1 + if until_bip65_activation > 0: + jm_single().bc_interface.tick_forward_chain(until_bip65_activation) + diff --git a/jmclient/test/test_tx_creation.py b/jmclient/test/test_tx_creation.py index bfce319..d251b43 100644 --- a/jmclient/test/test_tx_creation.py +++ b/jmclient/test/test_tx_creation.py @@ -5,7 +5,8 @@ network to check validity.''' import time import binascii import struct -from commontest import make_wallets, make_sign_and_push +from binascii import unhexlify +from commontest import make_wallets, make_sign_and_push, ensure_bip65_activated import jmbitcoin as bitcoin import pytest @@ -190,7 +191,7 @@ def test_spend_p2sh_utxos(setup_tx_creation): tx = bitcoin.mktx(ins, outs) sigs = [] for priv in privs[:2]: - sigs.append(bitcoin.multisign(tx, 0, script, binascii.hexlify(priv).decode('ascii'))) + sigs.append(bitcoin.get_p2sh_signature(tx, 0, script, binascii.hexlify(priv).decode('ascii'))) tx = bitcoin.apply_multisignatures(tx, 0, script, sigs) txid = jm_single().bc_interface.pushtx(tx) assert txid @@ -244,8 +245,8 @@ def test_spend_p2wsh(setup_tx_creation): privs = [binascii.hexlify(priv).decode('ascii') for priv in privs] pubs = [bitcoin.privkey_to_pubkey(priv) for priv in privs] redeemScripts = [bitcoin.mk_multisig_script(pubs[i:i+2], 2) for i in [0, 2]] - scriptPubKeys = [bitcoin.pubkeys_to_p2wsh_script(pubs[i:i+2]) for i in [0, 2]] - addresses = [bitcoin.pubkeys_to_p2wsh_address(pubs[i:i+2]) for i in [0, 2]] + scriptPubKeys = [bitcoin.pubkeys_to_p2wsh_multisig_script(pubs[i:i+2]) for i in [0, 2]] + addresses = [bitcoin.pubkeys_to_p2wsh_multisig_address(pubs[i:i+2]) for i in [0, 2]] #pay into it wallet_service = make_wallets(1, [[3, 0, 0, 0, 0]], 3)[0]['wallet'] wallet_service.sync_wallet(fast=True) @@ -268,7 +269,7 @@ def test_spend_p2wsh(setup_tx_creation): sigs = [] for priv in privs[i*2:i*2+2]: # sign input j with each of 2 keys - sig = bitcoin.multisign(tx, i, redeemScripts[i], priv, amount=amount) + sig = bitcoin.get_p2sh_signature(tx, i, redeemScripts[i], priv, amount=amount) sigs.append(sig) # check that verify_tx_input correctly validates; assert bitcoin.verify_tx_input(tx, i, scriptPubKeys[i], sig, @@ -278,6 +279,47 @@ def test_spend_p2wsh(setup_tx_creation): txid = jm_single().bc_interface.pushtx(tx) assert txid +def test_spend_freeze_script(setup_tx_creation): + ensure_bip65_activated() + + wallet_service = make_wallets(1, [[3, 0, 0, 0, 0]], 3)[0]['wallet'] + wallet_service.sync_wallet(fast=True) + + mediantime = jm_single().bc_interface.rpc("getblockchaininfo", [])["mediantime"] + + timeoffset_success_tests = [(2, False), (-60*60*24*30, True), (60*60*24*30, False)] + + for timeoffset, required_success in timeoffset_success_tests: + #generate keypair + priv = "aa"*32 + "01" + pub = unhexlify(bitcoin.privkey_to_pubkey(priv)) + addr_locktime = mediantime + timeoffset + redeem_script = bitcoin.mk_freeze_script(pub, addr_locktime) + script_pub_key = bitcoin.redeem_script_to_p2wsh_script(redeem_script) + regtest_vbyte = 100 + addr = bitcoin.script_to_address(script_pub_key, vbyte=regtest_vbyte) + + #fund frozen funds address + amount = 100000000 + funding_ins_full = wallet_service.select_utxos(0, amount) + funding_txid = make_sign_and_push(funding_ins_full, wallet_service, amount, output_addr=addr) + assert funding_txid + + #spend frozen funds + frozen_in = funding_txid + ":0" + output_addr = wallet_service.get_internal_addr(1) + miner_fee = 5000 + outs = [{'value': amount - miner_fee, 'address': output_addr}] + tx = bitcoin.mktx([frozen_in], outs, locktime=addr_locktime+1) + i = 0 + sig = bitcoin.get_p2sh_signature(tx, i, redeem_script, priv, amount) + + assert bitcoin.verify_tx_input(tx, i, script_pub_key, sig, pub, + scriptCode=redeem_script, amount=amount) + tx = bitcoin.apply_freeze_signature(tx, i, redeem_script, sig) + push_success = jm_single().bc_interface.pushtx(tx) + + assert push_success == required_success @pytest.fixture(scope="module") def setup_tx_creation(): diff --git a/jmclient/test/test_wallet.py b/jmclient/test/test_wallet.py index 97e2c50..4796c21 100644 --- a/jmclient/test/test_wallet.py +++ b/jmclient/test/test_wallet.py @@ -6,12 +6,13 @@ from binascii import hexlify, unhexlify import pytest import jmbitcoin as btc -from commontest import binarize_tx +from commontest import binarize_tx, ensure_bip65_activated from jmbase import get_log from jmclient import load_test_config, jm_single, \ SegwitLegacyWallet,BIP32Wallet, BIP49Wallet, LegacyWallet,\ VolatileStorage, get_network, cryptoengine, WalletError,\ - SegwitWallet, WalletService + SegwitWallet, WalletService, SegwitLegacyWalletFidelityBonds,\ + FidelityBondMixin, FidelityBondWatchonlyWallet, wallet_gettimelockaddress from test_blockchaininterface import sync_test_wallet testdir = os.path.dirname(os.path.realpath(__file__)) @@ -239,6 +240,72 @@ def test_bip32_addresses_p2sh_p2wpkh(setup_wallet, mixdepth, internal, index, ad assert wif == wallet.get_wif(mixdepth, internal, index) assert address == wallet.get_addr(mixdepth, internal, index) +@pytest.mark.parametrize('index,timenumber,address,wif', [ + [0, 0, 'bcrt1qndcqwedwa4lu77ryqpvp738d6p034a2fv8mufw3pw5smfcn39sgqpesn76', 'cST4g5R3mKp44K4J8PRVyys4XJu6EFavZyssq67PJKCnbhjdEdBY'], + [0, 50, 'bcrt1q73zhrfcu0ttkk4er9esrmvnpl6wpzhny5aly97jj9nw52agf8ncqjv8rda', 'cST4g5R3mKp44K4J8PRVyys4XJu6EFavZyssq67PJKCnbhjdEdBY'], + [5, 0, 'bcrt1qz5208jdm6399ja309ra28d0a34qlt0859u77uxc94v5mgk7auhtssau4pw', 'cRnUaBYTmyZURPe72YCrtvgxpBMvLKPZaCoXvKuWRPMryeJeAZx2'], + [9, 1, 'bcrt1qa7pd6qnadpmlm29vtvqnykalc34tr33eclaz7eeqal59n4gwr28qwnka2r', 'cQCxEPCWMwXVB16zCikDBTXMUccx6ioHQipPhYEp1euihkJUafyD'] +]) +def test_bip32_timelocked_addresses(setup_wallet, index, timenumber, address, wif): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + + entropy = unhexlify('2e0339ba89b4a1272cdf78b27ee62669ee01992a59e836e2807051be128ca817') + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize( + storage, get_network(), entropy=entropy, max_mixdepth=1) + wallet = SegwitLegacyWalletFidelityBonds(storage) + mixdepth = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + + #wallet needs to know about the script beforehand + wallet.get_script_and_update_map(mixdepth, address_type, index, timenumber) + + assert address == wallet.get_addr(mixdepth, address_type, index, timenumber) + assert wif == wallet.get_wif_path(wallet.get_path(mixdepth, address_type, index, timenumber)) + +@pytest.mark.parametrize('timenumber,locktime_string', [ + [0, "2020-01"], + [20, "2021-09"], + [100, "2028-05"], + [150, "2032-07"], + [350, "2049-03"] +]) +def test_gettimelockaddress_method(setup_wallet, timenumber, locktime_string): + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize(storage, get_network()) + wallet = SegwitLegacyWalletFidelityBonds(storage) + + m = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + index = wallet.get_next_unused_index(m, address_type) + script = wallet.get_script_and_update_map(m, address_type, index, + timenumber) + addr = wallet.script_to_addr(script) + + addr_from_method = wallet_gettimelockaddress(wallet, locktime_string) + + assert addr == addr_from_method + +@pytest.mark.parametrize('index,wif', [ + [0, 'cMg9eH3fW2JDSyggvXucjmECRwiheCMDo2Qik8y1keeYaxynzrYa'], + [9, 'cURA1Qgxhd7QnhhwxCnCHD4pZddVrJdu2BkTdzNaTp9owRSkUvPy'], + [50, 'cRTaHZ1eezb8s6xsT2V7EAevYToQMi7cxQD9vgFZzaJZDfhMhf3c'] +]) +def test_bip32_burn_keys(setup_wallet, index, wif): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + + entropy = unhexlify('2e0339ba89b4a1272cdf78b27ee62669ee01992a59e836e2807051be128ca817') + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize( + storage, get_network(), entropy=entropy, max_mixdepth=1) + wallet = SegwitLegacyWalletFidelityBonds(storage) + mixdepth = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_BURN_ID + + #advance index_cache enough + wallet.set_next_index(mixdepth, address_type, index, force=True) + + assert wif == wallet.get_wif_path(wallet.get_path(mixdepth, address_type, index)) def test_import_key(setup_wallet): jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') @@ -331,6 +398,29 @@ def test_signing_simple(setup_wallet, wallet_cls, type_check): txout = jm_single().bc_interface.pushtx(btc.serialize(tx)) assert txout +def test_timelocked_output_signing(setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + ensure_bip65_activated() + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize(storage, get_network()) + wallet = SegwitLegacyWalletFidelityBonds(storage) + + index = 0 + timenumber = 0 + script = wallet.get_script_and_update_map( + FidelityBondMixin.FIDELITY_BOND_MIXDEPTH, + FidelityBondMixin.BIP32_TIMELOCK_ID, index, timenumber) + utxo = fund_wallet_addr(wallet, wallet.script_to_addr(script)) + timestamp = wallet._time_number_to_timestamp(timenumber) + + tx = btc.deserialize(btc.mktx(['{}:{}'.format( + hexlify(utxo[0]).decode('ascii'), utxo[1])], + [btc.p2sh_scriptaddr(b"\x00",magicbyte=196) + ':' + str(10**8 - 9000)], + locktime=timestamp+1)) + tx = wallet.sign_tx(tx, {0: (script, 10**8)}) + txout = jm_single().bc_interface.pushtx(btc.serialize(tx)) + assert txout + def test_get_bbm(setup_wallet): jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') amount = 10**8 @@ -520,7 +610,7 @@ def test_set_next_index(setup_wallet): def test_path_repr(setup_wallet): wallet = get_populated_wallet() - path = wallet.get_path(2, False, 0) + path = wallet.get_path(2, BIP32Wallet.ADDRESS_TYPE_EXTERNAL, 0) path_repr = wallet.get_path_repr(path) path_new = wallet.path_repr_to_path(path_repr) @@ -537,6 +627,37 @@ def test_path_repr_imported(setup_wallet): assert path_new == path +@pytest.mark.parametrize('timenumber,timestamp', [ + [0, 1577836800], + [50, 1709251200], + [300, 2366841600], + [400, None], #too far in the future + [-1, None] #before epoch +]) +def test_timenumber_to_timestamp(setup_wallet, timenumber, timestamp): + try: + implied_timestamp = FidelityBondMixin._time_number_to_timestamp( + timenumber) + assert implied_timestamp == timestamp + except ValueError: + #None means the timenumber is intentionally invalid + assert timestamp == None + +@pytest.mark.parametrize('timestamp,timenumber', [ + [1577836800, 0], + [1709251200, 50], + [2366841600, 300], + [1577836801, None], #not exactly midnight on first of month + [2629670400, None], #too far in future + [1575158400, None] #before epoch +]) +def test_timestamp_to_timenumber(setup_wallet, timestamp, timenumber): + try: + implied_timenumber = FidelityBondMixin.timestamp_to_time_number( + timestamp) + assert implied_timenumber == timenumber + except ValueError: + assert timenumber == None def test_wrong_wallet_cls(setup_wallet): storage = VolatileStorage() @@ -658,6 +779,42 @@ def test_wallet_mixdepth_decrease(setup_wallet): # because we explicitly ask for a specific mixdepth assert utxo in new_wallet.select_utxos_(max_mixdepth, 10**7) +def test_watchonly_wallet(setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize(storage, get_network()) + wallet = SegwitLegacyWalletFidelityBonds(storage) + + paths = [ + "m/49'/1'/0'/0/0", + "m/49'/1'/0'/1/0", + "m/49'/1'/0'/2/0:1577836800", + "m/49'/1'/0'/2/0:2314051200" + ] + burn_path = "m/49'/1'/0'/3/0" + + scripts = [wallet.get_script_from_path(wallet.path_repr_to_path(path)) + for path in paths] + privkey, engine = wallet._get_key_from_path(wallet.path_repr_to_path(burn_path)) + burn_pubkey = engine.privkey_to_pubkey(privkey) + + master_pub_key = wallet.get_bip32_pub_export( + FidelityBondMixin.FIDELITY_BOND_MIXDEPTH) + watchonly_storage = VolatileStorage() + entropy = FidelityBondMixin.get_xpub_from_fidelity_bond_master_pub_key( + master_pub_key).encode() + FidelityBondWatchonlyWallet.initialize(watchonly_storage, get_network(), + entropy=entropy) + watchonly_wallet = FidelityBondWatchonlyWallet(watchonly_storage) + + watchonly_scripts = [watchonly_wallet.get_script_from_path( + watchonly_wallet.path_repr_to_path(path)) for path in paths] + privkey, engine = wallet._get_key_from_path(wallet.path_repr_to_path(burn_path)) + watchonly_burn_pubkey = engine.privkey_to_pubkey(privkey) + + for script, watchonly_script in zip(scripts, watchonly_scripts): + assert script == watchonly_script + assert burn_pubkey == watchonly_burn_pubkey @pytest.fixture(scope='module') def setup_wallet(): diff --git a/scripts/joinmarket-qt.py b/scripts/joinmarket-qt.py index 5ae7f28..d314ee1 100755 --- a/scripts/joinmarket-qt.py +++ b/scripts/joinmarket-qt.py @@ -75,7 +75,8 @@ from jmclient import load_program_config, get_network, update_persist_config,\ get_tumble_log, restart_wait, tumbler_filter_orders_callback,\ wallet_generate_recover_bip39, wallet_display, get_utxos_enabled_disabled,\ NO_ROUNDING, get_max_cj_fee_values, get_default_max_absolute_fee, \ - get_default_max_relative_fee, RetryableStorageError, add_base_options + get_default_max_relative_fee, RetryableStorageError, add_base_options, \ + FidelityBondMixin from qtsupport import ScheduleWizard, TumbleRestartWizard, config_tips,\ config_types, QtHandler, XStream, Buttons, OkButton, CancelButton,\ PasswordDialog, MyTreeWidget, JMQtMessageBox, BLUE_FG,\ @@ -1712,6 +1713,8 @@ class JMMainWindow(QMainWindow): return False # only used for GUI display on regtest: self.testwalletname = wallet.seed = str(firstarg) + if isinstance(wallet, FidelityBondMixin): + raise Exception("Fidelity bond wallets not supported by Qt") if 'listunspent_args' not in jm_single().config.options('POLICY'): jm_single().config.set('POLICY', 'listunspent_args', '[0]') assert wallet, "No wallet loaded" @@ -1862,7 +1865,8 @@ class JMMainWindow(QMainWindow): enter_wallet_password_callback=self.getPassword, enter_wallet_file_name_callback=self.getWalletFileName, enter_if_use_seed_extension=self.promptUseMnemonicExtension, - enter_seed_extension_callback=self.promptInputMnemonicExtension) + enter_seed_extension_callback=self.promptInputMnemonicExtension, + enter_do_support_fidelity_bonds=lambda: False) if not success: JMQtMessageBox(self, "Failed to create new wallet file.", diff --git a/scripts/sendpayment.py b/scripts/sendpayment.py index 850c57e..8ee03d9 100755 --- a/scripts/sendpayment.py +++ b/scripts/sendpayment.py @@ -12,8 +12,8 @@ from twisted.internet import reactor import pprint from jmclient import Taker, P2EPTaker, load_program_config, get_schedule,\ - JMClientProtocolFactory, start_reactor, validate_address, jm_single,\ - estimate_tx_fee, direct_send, WalletService,\ + JMClientProtocolFactory, start_reactor, validate_address, is_burn_destination, \ + jm_single, estimate_tx_fee, direct_send, WalletService,\ open_test_wallet_maybe, get_wallet_path, NO_ROUNDING, \ get_sendpayment_parser, get_max_cj_fee_values, check_regtest from twisted.python.log import startLogging @@ -83,8 +83,13 @@ def main(): destaddr = args[2] mixdepth = options.mixdepth addr_valid, errormsg = validate_address(destaddr) - if not addr_valid: + command_to_burn = (is_burn_destination(destaddr) and sweeping and + options.makercount == 0 and not options.p2ep) + if not addr_valid and not command_to_burn: jmprint('ERROR: Address invalid. ' + errormsg, "error") + if is_burn_destination(destaddr): + jmprint("The required options for burning coins are zero makers" + + " (-N 0), sweeping (amount = 0) and not using P2EP", "info") sys.exit(EXIT_ARGERROR) if sweeping == False and amount < DUST_THRESHOLD: jmprint('ERROR: Amount ' + btc.amount_to_str(amount) +