Compare commits

...

101 Commits

Author SHA1 Message Date
zebra-lucky 61c5b13e9d add start-dn script with testnet4 support 7 months ago
zebra-lucky b0c3f5c1fd frost-wallet-dev.md: add diagrams on DKG/FROST commands 7 months ago
zebra-lucky 87f9c513b9 fix yieldgenerator.py, wallet_rpc.py, test_wallet_rpc.py 7 months ago
zebra-lucky 530634d0c3 test_websocket.py: more informative attr name 7 months ago
zebra-lucky f892cb0843 update twisted, klein versions 7 months ago
zebra-lucky 97277cda73 fix test_client_protocol.py 7 months ago
zebra-lucky 7005be0774 fixes related to datetime/tzinfo 7 months ago
zebra-lucky d6360bd069 scripts_support.py: flake8 fixes 7 months ago
zebra-lucky 26d759364f add testnet4 support 7 months ago
zebra-lucky db7ec91545 fix on_dkg_init for work on multiple IRC channels 7 months ago
zebra-lucky bb71137fdd fix test_frost_clients.py, test_frost_ipc.py 7 months ago
zebra-lucky 12cc23fb69 frostround1: remove unnecessary param hostpubkeyhash 7 months ago
zebra-lucky 90af55f115 frost-wallet-dev.md: add more data on DKG commands 7 months ago
zebra-lucky 4eed1b1f58 frost-wallet-dev.md: update/add more data on FROST commands 7 months ago
zebra-lucky c918725f39 github build: split qt, snicker, obwatch builds 7 months ago
zebra-lucky 8bbde38b8a fix prev commit: wallet_utils.py: add testdkg command 7 months ago
zebra-lucky c045aa0f89 wallet_utils.py: add testdkg command 7 months ago
zebra-lucky 347a4a4077 fix BitcoinCoreInterface._rpc, twisted_sys_exit, finalize_main_task 7 months ago
zebra-lucky b8d8347165 replace deprecated utcfromtimestamp 7 months ago
zebra-lucky 308854a2fc replace deprecated utcnow with utc 7 months ago
zebra-lucky efe84521e0 replace deprecated log.warn part2 7 months ago
zebra-lucky 9c5dabc166 replace deprecated log.warn to warning 7 months ago
zebra-lucky 20188686b1 fix test/jmclient/test_taker.py 7 months ago
zebra-lucky 362f5c0bd0 scripts/receive-payjoin.py: fix _main as async 7 months ago
zebra-lucky 2d2e92f5a5 fixes on flake8 output 7 months ago
zebra-lucky dc6832e069 Qt: fix run join before info mbox is closed 7 months ago
zebra-lucky 69fa9c38bb fix prev commit: diable running on mainnet 7 months ago
zebra-lucky da96a3fd8e diable running on mainnet 7 months ago
zebra-lucky abe92572c1 mv scripts_support to jmclient, update snicker scripts 7 months ago
zebra-lucky 1cf23f7f79 scripts: update other scripts with wrap_main 7 months ago
zebra-lucky ac745f7d0e scripts: add finalize_main_task, use in wallet-tool.py, add-utxo.py 7 months ago
zebra-lucky 622146d59e scripts: add wrap_main, use in wallet-tool.py 7 months ago
zebra-lucky fc412b1904 Merge branch 'add_frost_channel_encryption' into add_frost 7 months ago
zebra-lucky 507839eaa5 encrypt FROST private messages 7 months ago
zebra-lucky 1fc9d787af build: fix joinmarket-clientserver.spec 7 months ago
zebra-lucky 4f6f2ee8c4 build.yml: use python-version instead matrix 7 months ago
zebra-lucky e55c19217f build.yml: use python3.12 7 months ago
zebra-lucky 6cde1cfb60 build.yml: downgrade runners 7 months ago
zebra-lucky efc1d5a911 build.yml: add permissions 7 months ago
zebra-lucky 32765f41ac unittests.yml: comment out ShellCheck 7 months ago
zebra-lucky 4e6be2ff90 run_tests.sh: replace backticks to $() 7 months ago
zebra-lucky 7d6c02bd95 tests: fix taproot/frost cfg paths part2 7 months ago
zebra-lucky f62da0f357 tests: fix taproot/frost cfg paths 7 months ago
zebra-lucky 22ea1842ba fix bencode/bdecode usage 7 months ago
zebra-lucky 2850de43fc unittests.yml: comment out flake8 lint 7 months ago
zebra-lucky 2a9214e6d5 shellcheck: ignore contrib/build-linux 7 months ago
zebra-lucky 5c1f336f93 fix unittests.yml, simplify run 7 months ago
zebra-lucky 2f8f68ae18 fix .github/workflows/unittests.yml and related 7 months ago
zebra-lucky 3654412945 add build scripts 7 months ago
zebra-lucky ec0dec0809 docs: additional note on TAPROOT/FROST testing 8 months ago
zebra-lucky c95567b4e2 docs: add notes on testing TAPROOT/FROST 8 months ago
zebra-lucky f3f12f80ae frost_ipc.py: handle FileNotFoundError for server sock 8 months ago
zebra-lucky f8abd64aee scripts/sendpayment.py: fix send_payjoin call 8 months ago
zebra-lucky 196ad1fbba Qt: fix send_payjoin call 8 months ago
zebra-lucky 5586b24198 Qt: fix QtAsyncio.run args, handle SystemExit 8 months ago
zebra-lucky d6953f2ca4 Qt: fix dialogs/wizards parent/modality 8 months ago
zebra-lucky 54109ba1d8 Qt: fix JMQtMessageBox call 8 months ago
zebra-lucky 1a3149e312 Qt: move more code to main fn 8 months ago
zebra-lucky febd1bdb8d Qt: fix tx history file placement/behavior 8 months ago
zebra-lucky ee05fcbc73 fix test_websocket.py sporadic fails 8 months ago
zebra-lucky 4f9cb3cebf Qt: do not strip on password check 8 months ago
zebra-lucky c6ffb5d052 Qt: bugfixes 8 months ago
zebra-lucky 0691795322 Qt: replace QInputDialog with JMInputDialog 8 months ago
zebra-lucky 3bc8a4acb3 Qt: add JMInputDialog with open/async support 8 months ago
zebra-lucky 971f6c87d7 Qt: add JMFileDialog with async/open usage 8 months ago
zebra-lucky 4ff252f9d6 Qt: use QMenu.popus instead exec 8 months ago
zebra-lucky b784ceaee9 Qt: fix TumbleRestartWizard to use open/asyncio 8 months ago
zebra-lucky 8c4796171d Qt: fix generateTumbleSchedule to use open/asyncio 8 months ago
zebra-lucky 4e3602beca Qt: fix seedEntry to use open/asyncio instead exec 8 months ago
zebra-lucky afc4c9dbc9 Qt: slight improve export keys usability 8 months ago
zebra-lucky 6f6d51e058 Qt: add JMExportPrivkeysDialog, use open/async code 8 months ago
zebra-lucky 52fca36150 Qt: don't use exec in showAboutDialog, openWallet 8 months ago
zebra-lucky 3d6e1befe6 Qt: add async JMPasswordDialog 8 months ago
zebra-lucky 607c3b0e6d wallet_utils: fix empty pwd for mainnet, fix FB usage 8 months ago
zebra-lucky 9612a2e8df Qt: couple of general PySide6 fixes 8 months ago
zebra-lucky 4799fffd41 Qt: remove direct usage of QMessageBox part1 8 months ago
zebra-lucky 3f8a9755ed Qt: fix async JMQtMessageBox usage part4 8 months ago
zebra-lucky f2f65fa085 wallet_utils:fix async callbacks call 8 months ago
zebra-lucky d5dbb2bae9 Qt: fix async JMQtMessageBox usage part3 8 months ago
zebra-lucky 62d94a9479 Qt: fix async JMQtMessageBox usage part2 8 months ago
zebra-lucky e90451ad71 followup 4eb63be0, fix async callbacks call 8 months ago
zebra-lucky 42b317d02e fix async callbacks call in jmclient code 8 months ago
zebra-lucky d318d3402b Qt: fix async JMQtMessageBox usage part1 8 months ago
zebra-lucky 847c9216d2 Qt: make JMQtMessageBox async, fix closeEvent usage 8 months ago
zebra-lucky 440a85a534 fix sequential tests run 8 months ago
zebra-lucky 092ff46219 fix test/unified/test_e2e_coinjoin.py 8 months ago
zebra-lucky 80a256539f fix usage of asyncio TestCase 8 months ago
zebra-lucky 1b9eb8b1b9 fix asyncio.iscoroutine usage 8 months ago
zebra-lucky ee8cbceda7 fix test_taproot_wallet.py test_watchonly_wallet 8 months ago
zebra-lucky 9dc1fcc6f8 add TaprootFidelityBondWatchonlyWallet 8 months ago
zebra-lucky 553894e304 fix dkg_recover, add test_dkg_recover 8 months ago
zebra-lucky d5213ec45a fix test/unified/test_segwit.py 8 months ago
zebra-lucky d512c58543 fix test/unified/test_bumpfee.py 8 months ago
zebra-lucky 6f82bb1afb test_frost_ref.py: fix vectors path, iterations fixture 8 months ago
zebra-lucky 42cd66a43d update bip-frost-signing to commit f5ea4a5b 8 months ago
zebra-lucky 37034ce57f frost_clients.py: fix Scalar.from_bytes usage 8 months ago
zebra-lucky 0dce982dea update bip-frost-dkg to commit 0f9e4b95 8 months ago
zebra-lucky faabb25ea5 add new tests for Taproot/FROST 8 months ago
zebra-lucky 456fbfaef3 fix existing tests 8 months ago
zebra-lucky dcfe04560b add FrostWallet 8 months ago
zebra-lucky 6d05c1039c add TaprootWallet 8 months ago
  1. 105
      .github/workflows/build.yml
  2. 24
      .github/workflows/unittests.yml
  3. 3
      .gitignore
  4. 106
      conftest.py
  5. 155
      contrib/build-linux/pyinstaller-build/build.sh
  6. 84
      contrib/build-linux/pyinstaller-build/joinmarket-clientserver-obwatch.spec
  7. 77
      contrib/build-linux/pyinstaller-build/joinmarket-clientserver-qt.spec
  8. 82
      contrib/build-linux/pyinstaller-build/joinmarket-clientserver-snicker.spec
  9. 91
      contrib/build-linux/pyinstaller-build/joinmarket-clientserver.spec
  10. 79
      contrib/build-linux/pyinstaller-build/start-dn.spec
  11. 14
      docs/TESTING.md
  12. 306
      docs/frost-wallet-dev.md
  13. 68
      docs/frost-wallet.md
  14. 27
      docs/taproot-wallet.md
  15. 23
      pyproject.toml
  16. 25
      scripts/add-utxo.py
  17. 56
      scripts/bdecode.py
  18. 68
      scripts/bencode.py
  19. 18
      scripts/bond-calculator.py
  20. 36
      scripts/bumpfee.py
  21. 23
      scripts/genwallet.py
  22. 1352
      scripts/joinmarket-qt.py
  23. 27
      scripts/obwatch/ob-watcher.py
  24. 303
      scripts/qtsupport.py
  25. 25
      scripts/receive-payjoin.py
  26. 78
      scripts/sendpayment.py
  27. 27
      scripts/sendtomany.py
  28. 33
      scripts/snicker/create-snicker-proposal.py
  29. 25
      scripts/snicker/receive-snicker.py
  30. 22
      scripts/snicker/snicker-finder.py
  31. 32
      scripts/snicker/snicker-recovery.py
  32. 34
      scripts/snicker/snicker-seed-tx.py
  33. 25
      scripts/snicker/snicker-server.py
  34. 31
      scripts/tumbler.py
  35. 22
      scripts/wallet-tool.py
  36. 27
      scripts/yg-privacyenhanced.py
  37. 19
      scripts/yield-generator-basic.py
  38. 3
      src/jmbase/__init__.py
  39. 192
      src/jmbase/commands.py
  40. 31
      src/jmbase/support.py
  41. 243
      src/jmbitcoin/scripteval.py
  42. 10
      src/jmbitcoin/secp256k1_deterministic.py
  43. 5
      src/jmbitcoin/secp256k1_main.py
  44. 126
      src/jmbitcoin/secp256k1_transaction.py
  45. 26
      src/jmclient/__init__.py
  46. 3
      src/jmclient/auth.py
  47. 97
      src/jmclient/blockchaininterface.py
  48. 8
      src/jmclient/cli_options.py
  49. 454
      src/jmclient/client_protocol.py
  50. 9
      src/jmclient/commitment_utils.py
  51. 80
      src/jmclient/configure.py
  52. 129
      src/jmclient/cryptoengine.py
  53. 49
      src/jmclient/descriptor.py
  54. 1050
      src/jmclient/frost_clients.py
  55. 275
      src/jmclient/frost_ipc.py
  56. 10
      src/jmclient/jsonrpc.py
  57. 75
      src/jmclient/maker.py
  58. 18
      src/jmclient/output.py
  59. 132
      src/jmclient/payjoin.py
  60. 5
      src/jmclient/podle.py
  61. 35
      src/jmclient/scripts_support.py
  62. 10
      src/jmclient/snicker_receiver.py
  63. 183
      src/jmclient/storage.py
  64. 16
      src/jmclient/support.py
  65. 271
      src/jmclient/taker.py
  66. 43
      src/jmclient/taker_utils.py
  67. 1318
      src/jmclient/wallet.py
  68. 118
      src/jmclient/wallet_rpc.py
  69. 341
      src/jmclient/wallet_service.py
  70. 596
      src/jmclient/wallet_utils.py
  71. 4
      src/jmclient/websocketserver.py
  72. 114
      src/jmclient/yieldgenerator.py
  73. 2
      src/jmdaemon/__init__.py
  74. 313
      src/jmdaemon/daemon_protocol.py
  75. 4
      src/jmdaemon/irc.py
  76. 199
      src/jmdaemon/message_channel.py
  77. 85
      src/jmdaemon/onionmc.py
  78. 5
      src/jmdaemon/orderbookwatch.py
  79. 22
      src/jmdaemon/protocol.py
  80. 21
      src/jmfrost/__init__.py
  81. 1310
      src/jmfrost/chilldkg_ref/README.md
  82. 3
      src/jmfrost/chilldkg_ref/__init__.py
  83. 871
      src/jmfrost/chilldkg_ref/chilldkg.py
  84. 341
      src/jmfrost/chilldkg_ref/encpedpop.py
  85. 327
      src/jmfrost/chilldkg_ref/simplpedpop.py
  86. 103
      src/jmfrost/chilldkg_ref/util.py
  87. 146
      src/jmfrost/chilldkg_ref/vss.py
  88. 751
      src/jmfrost/frost_ref/README.md
  89. 1
      src/jmfrost/frost_ref/__init__.py
  90. 442
      src/jmfrost/frost_ref/reference.py
  91. 10
      src/jmfrost/secp256k1lab/CHANGELOG.md
  92. 23
      src/jmfrost/secp256k1lab/COPYING
  93. 13
      src/jmfrost/secp256k1lab/README.md
  94. 0
      src/jmfrost/secp256k1lab/__init__.py
  95. 73
      src/jmfrost/secp256k1lab/bip340.py
  96. 16
      src/jmfrost/secp256k1lab/ecdh.py
  97. 15
      src/jmfrost/secp256k1lab/keys.py
  98. 454
      src/jmfrost/secp256k1lab/secp256k1.py
  99. 24
      src/jmfrost/secp256k1lab/util.py
  100. 2
      src/jmqtui/_compile.py
  101. Some files were not shown because too many files have changed in this diff Show More

105
.github/workflows/build.yml

@ -0,0 +1,105 @@
name: Build release workflow
on:
push:
tags:
- '*'
jobs:
create_release:
runs-on: ubuntu-22.04
name: Create github release
permissions:
contents: write
outputs:
upload_url: >
${{ steps.get_release.outputs.upload_url ||
steps.create_release.outputs.upload_url }}
steps:
- name: Try to Get Release
id: get_release
uses: zebra-lucky/actions-js-getRelease@0.0.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref }}
- name: Create Release
id: create_release
if: ${{ !steps.get_release.outputs.upload_url }}
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: false
prerelease: false
build_with_pyinstaller:
needs: create_release
runs-on: ubuntu-22.04
name: create PyInstaller build
permissions:
contents: write
steps:
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Set outputs for pkg_ver
id: set_vars
run: |
export JM_VERSION=${{ github.ref_name }}
echo "::set-output name=pkg_ver::$(echo $JM_VERSION)"
- name: Checkout
uses: actions/checkout@v1
- name: build
env:
JM_VERSION: ${{ github.ref_name }}
run: |
./contrib/build-linux/pyinstaller-build/build.sh
- name: Upload Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create_release.outputs.upload_url }}
asset_path: dist/joinmarket-clientserver-${{ github.ref_name}}.tgz
asset_name: joinmarket-clientserver-${{ github.ref_name}}.tgz
asset_content_type: application/octet-stream
- name: Upload Qt Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create_release.outputs.upload_url }}
asset_path: dist/joinmarket-clientserver-qt-${{ github.ref_name}}.tgz
asset_name: joinmarket-clientserver-qt-${{ github.ref_name}}.tgz
asset_content_type: application/octet-stream
- name: Upload Snicker Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create_release.outputs.upload_url }}
asset_path: dist/joinmarket-clientserver-snicker-${{ github.ref_name}}.tgz
asset_name: joinmarket-clientserver-snicker-${{ github.ref_name}}.tgz
asset_content_type: application/octet-stream
- name: Upload ObWatch Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create_release.outputs.upload_url }}
asset_path: dist/joinmarket-clientserver-obwatch-${{ github.ref_name}}.tgz
asset_name: joinmarket-clientserver-obwatch-${{ github.ref_name}}.tgz
asset_content_type: application/octet-stream
- name: Upload start-dn Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create_release.outputs.upload_url }}
asset_path: dist/joinmarket-clientserver-start-dn-${{ github.ref_name}}.tgz
asset_name: joinmarket-clientserver-start-dn-${{ github.ref_name}}.tgz
asset_content_type: application/octet-stream

24
.github/workflows/unittests.yml

@ -1,6 +1,6 @@
name: Python package
name: unittests workflow
on: [push, pull_request]
on: push
jobs:
build:
@ -8,16 +8,18 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-13, ubuntu-latest]
python-version: ["3.9", "3.13"]
bitcoind-version: ["28.3", "29.2"]
os: [ubuntu-latest]
python-version: ["3.12"]
bitcoind-version: ["28.3"]
steps:
- uses: actions/checkout@v3
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@master
with:
version: v0.9.0
# - name: Run ShellCheck
# uses: ludeeus/action-shellcheck@master
# with:
# version: v0.9.0
# ignore_paths: >-
# contrib/build-linux
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
@ -35,8 +37,8 @@ jobs:
run: |
bash -x ./install.sh --develop --with-qt
./jmvenv/bin/python -m pip install --upgrade pip
- name: Lint with flake8
run: source ./jmvenv/bin/activate && ./test/lint/lint-python.sh
# - name: Lint with flake8
# run: source ./jmvenv/bin/activate && ./test/lint/lint-python.sh
- name: Cache bitcoind
uses: actions/cache@v3
env:

3
.gitignore vendored

@ -17,7 +17,7 @@ bobkey
commitments_debug.txt
dummyext
deps/
jmvenv/
jmvenv*/
logs/
miniircd/
miniircd.tar.gz
@ -35,3 +35,4 @@ scripts/snicker/candidates.txt
.qt_for_python/
cmtdata/
**/build/
dist/

106
conftest.py

@ -7,6 +7,8 @@ from typing import Any, Tuple
import pytest
import jmclient # noqa: F401 install asyncioreactor
def get_bitcoind_version(bitcoind_path: str, conf: str) -> Tuple[int, int]:
"""
@ -136,7 +138,7 @@ def setup_regtest_bitcoind(pytestconfig):
bitcoin_path = pytestconfig.getoption("--btcroot")
bitcoind_path = os.path.join(bitcoin_path, "bitcoind")
bitcoincli_path = os.path.join(bitcoin_path, "bitcoin-cli")
start_cmd = f'{bitcoind_path} -regtest -daemon -conf={conf}'
start_cmd = f'{bitcoind_path} -regtest -daemon -txindex -conf={conf}'
stop_cmd = f'{bitcoincli_path} -regtest -rpcuser={rpcuser} -rpcpassword={rpcpassword} stop'
# determine bitcoind version
@ -172,3 +174,105 @@ def setup_regtest_bitcoind(pytestconfig):
local_command(stop_cmd)
# note, it is better to clean out ~/.bitcoin/regtest but too
# dangerous to automate it here perhaps
@pytest.fixture(scope="session")
def setup_regtest_taproot_bitcoind(pytestconfig):
"""
Setup regtest bitcoind and handle its clean up.
"""
conf = pytestconfig.getoption("--btcconf")
rpcuser = pytestconfig.getoption("--btcuser")
rpcpassword = pytestconfig.getoption("--btcpwd")
bitcoin_path = pytestconfig.getoption("--btcroot")
bitcoind_path = os.path.join(bitcoin_path, "bitcoind")
bitcoincli_path = os.path.join(bitcoin_path, "bitcoin-cli")
start_cmd = f'{bitcoind_path} -regtest -daemon -txindex -conf={conf}'
stop_cmd = f'{bitcoincli_path} -regtest -rpcuser={rpcuser} -rpcpassword={rpcpassword} stop'
# determine bitcoind version
try:
bitcoind_version = get_bitcoind_version(bitcoind_path, conf)
except RuntimeError as exc:
pytest.exit(f"Cannot setup tests, bitcoind failing.\n{exc}")
if bitcoind_version[0] >= 26:
start_cmd += ' -allowignoredconf=1'
local_command(start_cmd, bg=True)
root_cmd = f'{bitcoincli_path} -regtest -rpcuser={rpcuser} -rpcpassword={rpcpassword}'
wallet_name = 'jm-test-taproot-wallet'
# Bitcoin Core v0.21+ does not create default wallet
# From Bitcoin Core 0.21.0 there is support for descriptor wallets, which
# are default from 23.x+ (including 22.99.0 development versions).
# We don't support descriptor wallets yet.
if bitcoind_version[0] >= 27:
create_wallet = (f'{root_cmd} -rpcwait -named createwallet '
f'wallet_name={wallet_name} descriptors=true')
else:
pytest.exit("Cannot setup tests, bitcoind version "
"must be 27 or greater.\n")
local_command(create_wallet)
local_command(f'{root_cmd} loadwallet {wallet_name}')
for i in range(2):
cpe = local_command(f'{root_cmd} -rpcwallet={wallet_name} getnewaddress')
if cpe.returncode != 0:
pytest.exit(f"Cannot setup tests, bitcoin-cli failing.\n{cpe.stdout.decode('utf-8')}")
destn_addr = cpe.stdout[:-1].decode('utf-8')
local_command(f'{root_cmd} -rpcwallet={wallet_name} generatetoaddress 301 {destn_addr}')
sleep(1)
yield
# shut down bitcoind
local_command(stop_cmd)
# note, it is better to clean out ~/.bitcoin/regtest but too
# dangerous to automate it here perhaps
@pytest.fixture(scope="session")
def setup_regtest_frost_bitcoind(pytestconfig):
"""
Setup regtest bitcoind and handle its clean up.
"""
conf = pytestconfig.getoption("--btcconf")
rpcuser = pytestconfig.getoption("--btcuser")
rpcpassword = pytestconfig.getoption("--btcpwd")
bitcoin_path = pytestconfig.getoption("--btcroot")
bitcoind_path = os.path.join(bitcoin_path, "bitcoind")
bitcoincli_path = os.path.join(bitcoin_path, "bitcoin-cli")
start_cmd = f'{bitcoind_path} -regtest -daemon -txindex -conf={conf}'
stop_cmd = f'{bitcoincli_path} -regtest -rpcuser={rpcuser} -rpcpassword={rpcpassword} stop'
# determine bitcoind version
try:
bitcoind_version = get_bitcoind_version(bitcoind_path, conf)
except RuntimeError as exc:
pytest.exit(f"Cannot setup tests, bitcoind failing.\n{exc}")
if bitcoind_version[0] >= 26:
start_cmd += ' -allowignoredconf=1'
local_command(start_cmd, bg=True)
root_cmd = f'{bitcoincli_path} -regtest -rpcuser={rpcuser} -rpcpassword={rpcpassword}'
wallet_name = 'jm-test-frost-wallet'
# Bitcoin Core v0.21+ does not create default wallet
# From Bitcoin Core 0.21.0 there is support for descriptor wallets, which
# are default from 23.x+ (including 22.99.0 development versions).
# We don't support descriptor wallets yet.
if bitcoind_version[0] >= 27:
create_wallet = (f'{root_cmd} -rpcwait -named createwallet '
f'wallet_name={wallet_name} descriptors=true')
else:
pytest.exit("Cannot setup tests, bitcoind version "
"must be 27 or greater.\n")
local_command(create_wallet)
local_command(f'{root_cmd} loadwallet {wallet_name} true true')
for i in range(2):
cpe = local_command(f'{root_cmd} -rpcwallet={wallet_name} getnewaddress')
if cpe.returncode != 0:
pytest.exit(f"Cannot setup tests, bitcoin-cli failing.\n{cpe.stdout.decode('utf-8')}")
destn_addr = cpe.stdout[:-1].decode('utf-8')
local_command(f'{root_cmd} -rpcwallet={wallet_name} generatetoaddress 1 {destn_addr}')
sleep(1)
yield
# shut down bitcoind
local_command(stop_cmd)
# note, it is better to clean out ~/.bitcoin/regtest but too
# dangerous to automate it here perhaps

155
contrib/build-linux/pyinstaller-build/build.sh

@ -0,0 +1,155 @@
#!/bin/bash
set -e
export JM_VERSION="${JM_VERSION:-0.1-testbuild}"
PROJECT_ROOT=$(realpath "$(dirname "$(readlink -e "$0")")/../../..")
VENVPATH=$PROJECT_ROOT/jmvenv
JM_ROOT=$PROJECT_ROOT
sudo apt-get install -y python3-dev python3-pip python3-venv git \
build-essential automake pkg-config libtool libffi-dev libssl-dev
python3.12 -m venv $VENVPATH
. $VENVPATH/bin/activate
pip install .[gui]
pip install pyinstaller==6.14.2
# need to regenerate twisted/plugins/dropin.cache
python -c \
'from twisted.plugin import IPlugin, getPlugins; list(getPlugins(IPlugin))'
rm -rf deps
mkdir -p deps
cd deps
git clone https://github.com/bitcoin-core/secp256k1.git
cd secp256k1
git checkout v0.6.0
./autogen.sh
./configure --prefix $VENVPATH --enable-module-recovery \
--enable-experimental --enable-module-ecdh --enable-benchmark=no
make
make check
make install
cd ../..
rm -rf libsodium
git clone https://github.com/jedisct1/libsodium.git
cd libsodium
git checkout 1.0.20-RELEASE
./autogen.sh
./configure --prefix $VENVPATH
make check
sudo make install
cd ..
rm -rf libsodium
cp contrib/build-linux/pyinstaller-build/joinmarket-clientserver.spec .
pyinstaller -y joinmarket-clientserver.spec
ls -l dist/joinmarket-clientserver/
cd dist
mv joinmarket-clientserver joinmarket-clientserver-${JM_VERSION}
tar -czvf joinmarket-clientserver-${JM_VERSION}.tgz \
joinmarket-clientserver-${JM_VERSION}
ls -l
cd ..
rm -rf build joinmarket-clientserver.spec
cp contrib/build-linux/pyinstaller-build/joinmarket-clientserver-qt.spec .
pyinstaller -y joinmarket-clientserver-qt.spec
ls -l dist/joinmarket-clientserver/
cd dist
mv joinmarket-clientserver joinmarket-clientserver-qt-${JM_VERSION}
tar -czvf joinmarket-clientserver-qt-${JM_VERSION}.tgz \
joinmarket-clientserver-qt-${JM_VERSION}
ls -l
cd ..
rm -rf build joinmarket-clientserver-qt.spec
cp contrib/build-linux/pyinstaller-build/joinmarket-clientserver-snicker.spec .
pyinstaller -y joinmarket-clientserver-snicker.spec
ls -l dist/joinmarket-clientserver/
cd dist
mv joinmarket-clientserver joinmarket-clientserver-snicker-${JM_VERSION}
tar -czvf joinmarket-clientserver-snicker-${JM_VERSION}.tgz \
joinmarket-clientserver-snicker-${JM_VERSION}
ls -l
cd ..
rm -rf build joinmarket-clientserver-snicker.spec
cp contrib/build-linux/pyinstaller-build/joinmarket-clientserver-obwatch.spec .
pyinstaller -y joinmarket-clientserver-obwatch.spec
ls -l dist/joinmarket-clientserver/
cd dist
mv joinmarket-clientserver joinmarket-clientserver-obwatch-${JM_VERSION}
tar -czvf joinmarket-clientserver-obwatch-${JM_VERSION}.tgz \
joinmarket-clientserver-obwatch-${JM_VERSION}
ls -l
cd ..
rm -rf build joinmarket-clientserver-obwatch.spec
git clone https://github.com/zebra-lucky/joinmarket-custom-scripts
cd joinmarket-custom-scripts/
git checkout add-testnet4
cd ..
cp contrib/build-linux/pyinstaller-build/start-dn.spec .
pyinstaller -y start-dn.spec
ls -l dist/joinmarket-clientserver/
cd dist
mv joinmarket-clientserver joinmarket-clientserver-start-dn-${JM_VERSION}
tar -czvf joinmarket-clientserver-start-dn-${JM_VERSION}.tgz \
joinmarket-clientserver-start-dn-${JM_VERSION}
ls -l
cd ..
rm -rf build joinmarket-custom-scripts start-dn.spec

84
contrib/build-linux/pyinstaller-build/joinmarket-clientserver-obwatch.spec

@ -0,0 +1,84 @@
# -*- mode: python; coding: utf-8 -*-
import itertools
import os
from pathlib import Path
PROJECT_ROOT = os.path.abspath('.')
binaries = []
binaries += [(f'{PROJECT_ROOT}/jmvenv/lib/lib*', '.')]
datas = []
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/dropin.cache','twisted/plugins')]
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/__init__.py', 'twisted/plugins')]
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/txtorcon_endpoint_parser.py', 'twisted/plugins')]
datas += [(f'{PROJECT_ROOT}/scripts/obwatch/orderbook.html', '.')]
datas += [(f'{PROJECT_ROOT}/scripts/obwatch/vendor/bootstrap.min.css',
'vendor')]
datas += [(f'{PROJECT_ROOT}/scripts/obwatch/vendor/jquery-3.5.1.slim.min.js',
'vendor')]
datas += [(f'{PROJECT_ROOT}/scripts/obwatch/vendor/sorttable.js',
'vendor')]
scripts = [
'scripts/obwatch/ob-watcher.py',
]
hiddenimports = [
'chromalog.mark.helpers',
'chromalog.mark.helpers.simple',
'twisted.plugins',
'twisted.plugins.txtorcon_endpoint_parser',
]
a = {}
pyz = {}
exe = {}
for s in scripts:
a[s] = Analysis(
[s],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz[s] = PYZ(a[s].pure)
exe[s] = EXE(
pyz[s], a[s].scripts, [],
name=Path(s).stem,
exclude_binaries=True, debug=False, bootloader_ignore_signals=False,
strip=False, upx=True, console=True, disable_windowed_traceback=False,
argv_emulation=False, target_arch=None, codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
*list(exe.values()),
list(set(itertools.chain.from_iterable(b.binaries for b in a.values()))),
list(set(itertools.chain.from_iterable(d.datas for d in a.values()))),
strip=False,
upx=True,
upx_exclude=[],
name='joinmarket-clientserver',
)

77
contrib/build-linux/pyinstaller-build/joinmarket-clientserver-qt.spec

@ -0,0 +1,77 @@
# -*- mode: python; coding: utf-8 -*-
import itertools
import os
from pathlib import Path
PROJECT_ROOT = os.path.abspath('.')
binaries = []
binaries += [(f'{PROJECT_ROOT}/jmvenv/lib/lib*', '.')]
datas = []
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/dropin.cache','twisted/plugins')]
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/__init__.py', 'twisted/plugins')]
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/txtorcon_endpoint_parser.py', 'twisted/plugins')]
scripts = [
'scripts/joinmarket-qt.py',
]
hiddenimports = [
'chromalog.mark.helpers',
'chromalog.mark.helpers.simple',
'twisted.plugins',
'twisted.plugins.txtorcon_endpoint_parser',
]
a = {}
pyz = {}
exe = {}
for s in scripts:
a[s] = Analysis(
[s],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz[s] = PYZ(a[s].pure)
exe[s] = EXE(
pyz[s], a[s].scripts, [],
name=Path(s).stem,
exclude_binaries=True, debug=False, bootloader_ignore_signals=False,
strip=False, upx=True, console=True, disable_windowed_traceback=False,
argv_emulation=False, target_arch=None, codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
*list(exe.values()),
list(set(itertools.chain.from_iterable(b.binaries for b in a.values()))),
list(set(itertools.chain.from_iterable(d.datas for d in a.values()))),
strip=False,
upx=True,
upx_exclude=[],
name='joinmarket-clientserver',
)

82
contrib/build-linux/pyinstaller-build/joinmarket-clientserver-snicker.spec

@ -0,0 +1,82 @@
# -*- mode: python; coding: utf-8 -*-
import itertools
import os
from pathlib import Path
PROJECT_ROOT = os.path.abspath('.')
binaries = []
binaries += [(f'{PROJECT_ROOT}/jmvenv/lib/lib*', '.')]
datas = []
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/dropin.cache','twisted/plugins')]
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/__init__.py', 'twisted/plugins')]
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/txtorcon_endpoint_parser.py', 'twisted/plugins')]
scripts = [
'scripts/snicker/create-snicker-proposal.py',
'scripts/snicker/receive-snicker.py',
'scripts/snicker/snicker-finder.py',
'scripts/snicker/snicker-recovery.py',
'scripts/snicker/snicker-seed-tx.py',
'scripts/snicker/snicker-server.py',
]
hiddenimports = [
'chromalog.mark.helpers',
'chromalog.mark.helpers.simple',
'twisted.plugins',
'twisted.plugins.txtorcon_endpoint_parser',
]
a = {}
pyz = {}
exe = {}
for s in scripts:
a[s] = Analysis(
[s],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz[s] = PYZ(a[s].pure)
exe[s] = EXE(
pyz[s], a[s].scripts, [],
name=Path(s).stem,
exclude_binaries=True, debug=False, bootloader_ignore_signals=False,
strip=False, upx=True, console=True, disable_windowed_traceback=False,
argv_emulation=False, target_arch=None, codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
*list(exe.values()),
list(set(itertools.chain.from_iterable(b.binaries for b in a.values()))),
list(set(itertools.chain.from_iterable(d.datas for d in a.values()))),
strip=False,
upx=True,
upx_exclude=[],
name='joinmarket-clientserver',
)

91
contrib/build-linux/pyinstaller-build/joinmarket-clientserver.spec

@ -0,0 +1,91 @@
# -*- mode: python; coding: utf-8 -*-
import itertools
import os
from pathlib import Path
PROJECT_ROOT = os.path.abspath('.')
binaries = []
binaries += [(f'{PROJECT_ROOT}/jmvenv/lib/lib*', '.')]
datas = []
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/dropin.cache','twisted/plugins')]
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/__init__.py', 'twisted/plugins')]
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/txtorcon_endpoint_parser.py', 'twisted/plugins')]
scripts = [
'scripts/add-utxo.py',
'scripts/bond-calculator.py',
'scripts/bumpfee.py',
'scripts/genwallet.py',
'scripts/jmwalletd.py',
'scripts/joinmarketd.py',
'scripts/receive-payjoin.py',
'scripts/sendpayment.py',
'scripts/sendtomany.py',
'scripts/tumbler.py',
'scripts/wallet-tool.py',
'scripts/yg-privacyenhanced.py',
'scripts/yield-generator-basic.py',
]
hiddenimports = [
'chromalog.mark.helpers',
'chromalog.mark.helpers.simple',
'twisted.plugins',
# 'twisted.plugins.autobahn_endpoints',
# 'twisted.plugins.autobahn_twistd',
'twisted.plugins.txtorcon_endpoint_parser',
]
a = {}
pyz = {}
exe = {}
for s in scripts:
a[s] = Analysis(
[s],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz[s] = PYZ(a[s].pure)
exe[s] = EXE(
pyz[s], a[s].scripts, [],
name=Path(s).stem,
exclude_binaries=True, debug=False, bootloader_ignore_signals=False,
strip=False, upx=True, console=True, disable_windowed_traceback=False,
argv_emulation=False, target_arch=None, codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
*list(exe.values()),
list(set(itertools.chain.from_iterable(b.binaries for b in a.values()))),
list(set(itertools.chain.from_iterable(d.datas for d in a.values()))),
strip=False,
upx=True,
upx_exclude=[],
name='joinmarket-clientserver',
)

79
contrib/build-linux/pyinstaller-build/start-dn.spec

@ -0,0 +1,79 @@
# -*- mode: python; coding: utf-8 -*-
import itertools
import os
from pathlib import Path
PROJECT_ROOT = os.path.abspath('.')
binaries = []
binaries += [(f'{PROJECT_ROOT}/jmvenv/lib/lib*', '.')]
datas = []
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/dropin.cache','twisted/plugins')]
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/__init__.py', 'twisted/plugins')]
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/'
f'twisted/plugins/txtorcon_endpoint_parser.py', 'twisted/plugins')]
scripts = [
'joinmarket-custom-scripts/start-dn.py',
]
hiddenimports = [
'chromalog.mark.helpers',
'chromalog.mark.helpers.simple',
'twisted.plugins',
# 'twisted.plugins.autobahn_endpoints',
# 'twisted.plugins.autobahn_twistd',
'twisted.plugins.txtorcon_endpoint_parser',
]
a = {}
pyz = {}
exe = {}
for s in scripts:
a[s] = Analysis(
[s],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz[s] = PYZ(a[s].pure)
exe[s] = EXE(
pyz[s], a[s].scripts, [],
name=Path(s).stem,
exclude_binaries=True, debug=False, bootloader_ignore_signals=False,
strip=False, upx=True, console=True, disable_windowed_traceback=False,
argv_emulation=False, target_arch=None, codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
*list(exe.values()),
list(set(itertools.chain.from_iterable(b.binaries for b in a.values()))),
list(set(itertools.chain.from_iterable(d.datas for d in a.values()))),
strip=False,
upx=True,
upx_exclude=[],
name='joinmarket-clientserver',
)

14
docs/TESTING.md

@ -17,12 +17,26 @@ Have a `bitcoin.conf` ready in some location, whose contents only need to be:
rpcuser=bitcoinrpc
rpcpassword=123456abcdef
fallbackfee=0.0002
# txindex options is need to get non-wallet transactions with
# getrawtransaction. This data is need to perform signing of P2TR inputs.
txindex=1
```
**NOTE for TAPROOT/FROST**: keep attention to the option `txindex`,
wchich is now required in the `bitcoin.conf`
(any random password is fine of course). It is also advisable to wipe ~/.bitcoin/regtest first, in case it gets large and slow to process.
Then copy the `regtest_joinmarket.cfg` file from the `test/` directory to the `joinmarket-clientserver/` directory and rename it to `joinmarket.cfg`; you probably won't need to change anything in the file except perhaps the above password, and the `native` setting if you're doing bech32 wallet tests.
**NOTE for TAPROOT**: copy `regtest_taproot_joinmarket.cfg` file
from `test/` directory to the `joinmarket-clientserver/test_taproot` directory
and and rename it to `joinmarket.cfg`
**NOTE for FROST**: copy `regtest_frost_joinmarket.cfg` file
from `test/` directory to the `joinmarket-clientserver/test_frost` directory
and and rename it to `joinmarket.cfg`
Run the test suite via pytest:
(jmvenv)$ pytest --btcconf=/path/to/bitcoin.conf --btcroot=/path/to/bitcoin/bin/ --btcpwd=123456abcdef --nirc=2 -p no:warnings

306
docs/frost-wallet-dev.md

@ -0,0 +1,306 @@
# FROST P2TR wallet development details
**NOTE**: minimal python version is python3.12
## FrostWallet storages
`FrostWallet` have two additional storages in addtion to wallet `Storage`:
- `DKGStorage` with DKG data
- `DKGRecoveryStorage` with DKG recovery data (unencrypted)
They are loaded only for DKG/FROST support and not loaded on usual wallet
usage.
Usual wallet usage interact with FROST/DKG functionality via IPC code in
`frost_ipc.py` (currently `AF_UNIX` socket for simplicity).
`jmclient.wallet_utils.open_wallet` has two new parameters:
- `load_dkg=False`: by default do not load `DKGStorage`
- `dkg_read_only=True`: load `DKGStorage` for read only commands
Additionally `open_wallet` params `read_only` and `dkg_read_only` can not
be mutually unset by design.
## Structure of DKG data in the DKGStorage
```
"dkg": {
"sessions": {
"md_type_idx": session_id,
...
},
"pubkey": {
"session_id": threshold_pubkey,
...
},
"pubshares": {
"session_id": [pubshare1, pubshare2, ...],
...
},
"secshare": {
"session_id": secshare,
...
},
"hostpubkeys": {
"session_id": [hostpubkey1, hostpubkey2, ...],
...
},
"t": {
"session_id": t,
...
}
}
```
Where `md_type_idx` is a serialization in bytes of `mixdepth`, `address_type`,
`index` of pubkey as in the HD wallet derivations.
## Overall information
In the code used twisted `asyncioreactor` in place of standard twisted reactor.
Initialization is done as early as possible in `jmclient/__init__.py`.
Classes for wallets: `TaprootWallet`, `FrostWallet` in the `jmclient/wallet.py`
Utility class `DKGManager` in the `jmclient/wallet.py`.
Engine classes `BTC_P2TR(BTCEngine)`, `BTC_P2TR_FROST(BTC_P2TR)` in the
`jmclient/cryptoengine.py`.
## `scripts/wallet-tool.py` commands
- `hostpubkey`: display host public key
- `servefrost`: run only as DKG/FROST support (separate process which need
to be run permanently)
- `dkgrecover`: recover DKG sessions from DKG recovery file
- `dkgls`: display FrostWallet DKG data
- `dkgrm`: rm FrostWallet DKG data by `session_id` list
- `recdkgls`: display Recovery DKG File data
- `recdkgrm`: rm Recovery DKG File data by `session_id` list
- `testdkg`: run only as test of DKG process
- `testfrost`: run only as test of FROST signing
## Description of `jmclient/frost_clients.py`
- `class DKGClient`: clent which support only DKG sessions over JM channels.
Uses `chilldkg` reference code from
https://github.com/BlockstreamResearch/bip-frost-dkg/, placed in the
`jmfrost/chilldkg_ref` package.
Uses channel level commands `dkginit`, `dkgpmsg1`, `dkgcmsg1`, `dkgpmsg2`,
`dkgcmsg2`, `dkgfinalized`, added to `jmdaemon/protocol.py`.
NOTE: `dkgfinalized` is used to ensure all DKG party saw `dkgcmsg2` and
saved DKG data to wallet/recovery data.
Commands in the `jmbase/commands.py`: `JMDKGInit`, `JMDKGPMsg1`, `JMDKGCMsg1`,
`JMDKGPMsg2`, `MDKGCMsg2`, `JMDKGFinalized`, `JMDKGInitSeen`, `JMDKGPMsg1Seen`,
`JMDKGCMsg1Seen`, `JMDKGPMsg2Seen`, `JMDKGCMsg2Seen`, `JMDKGFinalizedSeen`.
Responders on the commands in the `jmclient/client_protocol.py`,
`jmdaemon/daemon_protocol.py`.
In the DKG sessions the party which need new pubkey is named Coordinator.
- `class FROSTClient(DKGClient)`: clent which support DKG/FROST sessions over
JM channels. Uses reference FROST code from
https://github.com/siv2r/bip-frost-signing/, placed in the
`jmfrost/frost_ref` package.
Uses channel level commands `frostreq`, `frostack`, `frostinit`, `frostround1`,
`frostagg1`, `frostround2`, added to `jmdaemon/protocol.py`.
Commands in the `jmbase/commands.py`: `JMFROSTReq`, `JMFROSTAck`,
`JMFROSTInit`, `JMFROSTRound1`, `JMFROSTAgg1`, `JMFROSTRound2`,
`JMFROSTReqSeen`, `JMFROSTAckSeen`, `JMFROSTInitSeen`, `JMFROSTRound1Seen`,
`JMFROSTAgg1Seen`, `JMFROSTRound2Seen`.
Responders on the commands in the `jmclient/client_protocol.py`,
`jmdaemon/daemon_protocol.py`.
In the FROST sessions the party which need new signature is named Coordinator.
## Details on DKG message channel commands
| Coordinator | | Party |
| :---------:|:----:|:-------:|
|!dkginit (public)|>>>||
||<<<|!dkgpmsg1 (private unencrypted)|
|!dkgcmsg1 (private unencrypted)|>>>||
||<<<|!dkgpmsg2 (private unencrypted)|
|!dkgcmsg2 (private unencrypted)|>>>||
||<<<|!dkgfinalied (private unencrypted)|
**dkginit**: public broadcast command from coordinator to request DKG
exchange
```
self.mcc.pubmsg(f'!dkginit {hostpubkeyhash} {session_id} {sig}')
```
- `hostpubkeyhash`: sha256 hash of wallet `hostpubkey` to identify
wallet to other DKG parties
- `session_id`: random 32 bytes to identify DKG session
- `sig`: Schnorr signature on `session_id` to verify with `hostpubkey` to
authenticate wallet
**dkgpmsg1**: private unencrypted command from parties to authenticate and
send EncPedPop `pmsg1` to coordinator
```
msg = f'{hostpubkeyhash} {session_id} {sig} {pmsg1}'
self.mcc.prepare_privmsg(nick, "dkgpmsg1", msg)
```
- `hostpubkeyhash`: sha256 hash of wallet `hostpubkey` to identify
wallet to coordinator
- `session_id`: 32 bytes to idenify DKG session
- `sig`: Schnorr signature on `session_id` to verify with `hostpubkey` to
authenticate wallet
- `pmsg1`: EncPedPop participants step1 message
**dkgcmsg1**: private unencrypted command from coordinator to send
EncPedPop `cmsg1` to DKG parties
```
msg = f'{session_id} {cmsg1}'
self.mcc.prepare_privmsg(nick, "dkgcmsg1", msg)
```
- `session_id`: 32 bytes to idenify DKG session
- `cmsg1`: EncPedPop coordinator step1 message
**dkgpmsg2**: private unencrypted command from parties to send
EncPedPop `pmsg2` to coordinator
```
msg = f'{session_id} {pmsg2}'
self.mcc.prepare_privmsg(nick, "dkgpmsg2", msg)
```
- `session_id`: 32 bytes to idenify DKG session
- `pmsg2`: EncPedPop participants step2 message
**dkgcmsg2**: private unencrypted command from coordinator to send
EncPedPop `cmsg2` and encrypted `ext_recovery` to DKG parties
```
msg = f'{session_id} {cmsg2} {ext_recovery}'
self.mcc.prepare_privmsg(nick, "dkgcmsg2", msg)
```
- `session_id`: 32 bytes to idenify DKG session
- `cmsg2`: EncPedPop coordinator step2 message
- `ext_recovery`: byte encoded and encrypted with `hostpubkey` tuple
`(mixdepth, address_type, index)`, which sent to DKG parties to write
with DKG recovery data
**dkgfinalized**: private unencrypted command from parties to coordinator
to confirm DKG session finished and all DKG data saved together with
`recovery data`, `ext_recovery`
```
msg = f'{session_id}'
self.mcc.prepare_privmsg(nick, "dkgfinalized", msg)
```
- `session_id`: 32 bytes to idenify DKG session
## Details on FROST message channel commands
| Coordinator | | Party |
| :---------:|:----:|:-------:|
|!frostreq (public)|>>>||
||<<<|!frostack (private unencrypted)|
|!frostinit (private encrypted)|>>>||
||<<<|!frostround1 (private encrypted)|
|!frostagg1 (private encrypted)|>>>||
||<<<|!frostround2 (private encrypted)|
**frostreq**: public broadcast command from coordinator to request encrypted
FROST exchange
```
req_msg = f'!frostreq {hostpubkeyhash} {sig} {session_id} {dh_pubk}'
self.mcc.pubmsg(req_msg)
```
- `hostpubkeyhash`: sha256 hash of wallet `hostpubkey` to identify
wallet to other FROST parties
- `sig`: Schnorr signature on `session_id` to verify with `hostpubkey` to
authenticate wallet
- `session_id`: random 32 bytes to identify FROST session
- `dh_pubk`: ECDH public key to create encrypted private messages for other
FROST commands
**frostack**: private unencrypted command from parties to acknowledge encrypted
FROST exchange
```
ack_msg = f'{hostpubkeyhash} {sig} {session_id} {dh_pubk}
self.mcc.prepare_privmsg(nick, 'frostack', ack_msg)
```
- `hostpubkeyhash`: sha256 hash of wallet `hostpubkey` to identify
wallet for coordinator
- `sig`: Schnorr signature on `session_id` to verify with `hostpubkey` to
authenticate wallet
- `session_id`: 32 bytes to idenify FROST session
- `dh_pubk`: ECDH public key to create encrypted private messages for other
FROST commands
**frostinit**: private encrypted command from coordinator to initiate
FROST exchange
```
init_msg = f'{session_id}'
self.mcc.prepare_privmsg(nick, 'frostinit', init_msg)
```
- `session_id`: 32 bytes to idenify FROST session
**frostround1**: private encrypted command from parties to send `pub_nonce`
part of FROST exchange
```
round1_msg = f'{session_id} pub_nonce}'
self.mcc.prepare_privmsg(nick, "frostround1", round1_msg)
```
- `session_id`: 32 bytes to idenify FROST session
- `pub_nonce`: public part of `sec_nonce`/`pub_nonce` pair
**frostagg1**: private encrypted command from coorinator to send aggregated
nonces data, DKG session id to get key data, ids of sign parties and
message to sign
```
agg1_msg = f'{session_id} {nonce_agg} {dkg_session_id} {ids} {msg}'
self.mcc.prepare_privmsg(nick, "frostagg1", agg1_msg)
```
- `session_id`: 32 bytes to idenify FROST session
- `nonce_agg`: aggregated pub nonces data
- `dkg_session_id`: bytes to idenify DKG session where key data for FROST
whas generated
- `ids`: FROST sign parties ids
- `msg`: 32 bytes message to sign
**frostround2**: private encrypted command from parties to send partial
signature for coordinator
```
msg = f'{session_id} {partial_sig}'
self.mcc.prepare_privmsg(nick, "frostround2", msg)
```
- `session_id`: 32 bytes to idenify FROST session
- `partial_sig`: partial FROST signature agregated later on coordinator
## Recovery storage, recovery data file.
ChillDKG recovery data is placed in the unencrypted recovery file with
the name `wallet.jmdat.dkg_recovery`. Code of `class DKGRecoveryStorage` is
placed in `jmclient/storage.py`
## Utility scripts
Currently changes in the code allow creation of unencrypted wallets, if
empty password is used.
- `scripts/bdecode.py`: allow decode wallet/recovery data files to stdout.
- `scripts/bencode.py`: allow allow encode text file to bencode format.
Separate options is presented to encode with DKG data file magic or DKG
recovery data file magic.

68
docs/frost-wallet.md

@ -0,0 +1,68 @@
# FROST P2TR wallet usage
**NOTE**: minimal python version is python3.12
To use FROST P2TR wallet you need (example for 2 of 3 FROST signing):
1. Add `txindex=1` to `bitcoin.conf`. This options is need to get non-wallet
transactions with `getrawtransaction`. This data is need to perform signing
of P2TR inputs.
2. Set `frost = true` in the `POLICY` section of `joinmarket.cfg`:
```
[POLICY]
...
# Use FROST P2TR SegWit wallet
frost = true
```
3. Create bitcoind watchonly descriptors wallet:
```
bitcoin-cli createwallet "wallet_name" true true
```
where `true true` is:
> `disable_private_keys`
> Disable the possibility of private keys
> (only watchonlys are possible in this mode).
> `blank`
> Create a blank wallet. A blank wallet has no keys or HD seed.
4. Get `hostpubkey` for wallet by running:
```
scripts/wallet-tool.py wallet.jmdat hostpubkey
...
021e99d8193b95da10f514556e98882bc2cebfd0ee0711fa71006cbc9e9a135b43
```
5. Repeat steps 1-4 for other FROST group wallets.
6. Gather hostpubkeys from step 4 and place to the `FROST` section
of `joinmarket.cfg` as the `hostpubkeys` value separated by `,`.
7. Add `t` (threshold) value to the `FROST` section of `joinmarket.cfg`:
```
[FROST]
hostpubkeys = 021e99d8193b95da...,03a2f4ce928da0f5...,02a1e2ee50187f3e...
t = 2
```
8. Run permanent FROST processes with `servefrost` command on `wallet1`,
`wallet2`, `wallet3`:
```
scripts/wallet-tool.py wallet.jmdat servefrost
```
9. Run `display` command on `wallet1`
```
scripts/wallet-tool.py wallet.jmdat display
```
The process of DKG sessions will start to generate pubkeys for the
wallet addresses. This can take several minutes.
10. Repeat step 9 to generate pubkeys for `wallet2`, `wallet3`.
11. Test FROST signing with `testfrost` command
```
scripts/wallet-tool.py wallet.jmdat testfrost
```

27
docs/taproot-wallet.md

@ -0,0 +1,27 @@
# Taproot P2TR wallet usage
To use P2TR wallet you need:
1. Add `txindex=1` to `bitcoin.conf`. This options is need to get non-wallet
transactions with `getrawtransaction`. This data is need to perform signing
of P2TR inputs.
2. Set `taproot = true` in the `POLICY` section of `joinmarket.cfg`:
```
[POLICY]
...
# Use Taproot P2TR SegWit wallet
taproot = true
```
3. Create bitcoind watchonly descriptors wallet:
```
bitcoin-cli createwallet "wallet_name" true true
```
where `true true` is:
> `disable_private_keys`
> Disable the possibility of private keys
> (only watchonlys are possible in this mode).
> `blank`
> Create a blank wallet. A blank wallet has no keys or HD seed.

23
pyproject.toml

@ -13,19 +13,19 @@ dependencies = [
"chromalog==1.0.5",
"cryptography==42.0.4",
"service-identity==21.1.0",
"twisted==24.7.0",
"twisted@git+https://github.com/zebra-lucky/twisted@fix_from_pr11890-25.5.0#egg=twisted",
"txtorcon==23.11.0",
]
[project.optional-dependencies]
jmbitcoin = [
"python-bitcointx==1.1.5",
"python-bitcointx@git+https://github.com/zebra-lucky/python-bitcointx@disable_contextvars#egg=python-bitcointx",
]
jmclient = [
"argon2_cffi==21.3.0",
"autobahn==20.12.3",
"fastbencode==0.3.6",
"klein==20.6.0",
"klein==24.8.0",
"mnemonic==0.20",
"pyjwt==2.4.0",
"werkzeug==2.2.3",
@ -34,16 +34,18 @@ jmdaemon = [
"libnacl==1.8.0",
"pyopenssl==24.0.0",
]
jmfrost = [
]
jmqtui = [
"PyQt5!=5.15.0,!=5.15.1,!=5.15.2,!=6.0",
"PySide2!=5.15.0,!=5.15.1,!=5.15.2,!=6.0", # https://bugreports.qt.io/browse/QTBUG-88688
"PySide6==6.9.3", # https://bugreports.qt.io/browse/QTBUG-88688
"qrcode[pil]==7.3.1",
'pywin32; platform_system == "Windows"',
"qt5reactor==0.6.3",
"qt5reactor@git+https://github.com/zebra-lucky/qt5reactor@update_versioneer#egg=qt5reactor",
]
client = [
"joinmarket[jmclient]",
"joinmarket[jmbitcoin]",
"joinmarket[jmfrost]",
]
daemon = [
"joinmarket[jmdaemon]",
@ -54,14 +56,15 @@ services = [
]
test = [
"joinmarket[services]",
"coverage==5.2.1",
"coverage==7.8.2",
"flake8",
"freezegun",
"mock",
"pexpect",
"pytest-cov>=2.4.0,<2.6",
"pytest==6.2.5",
"pytest-cov==6.1.1",
"pytest==7.4.4",
"python-coveralls",
"unittest-parametrize==1.6.0",
]
gui = [
"joinmarket[services]",
@ -76,4 +79,4 @@ where = ["src"]
exclude = ["*.test"]
[tool.pytest.ini_options]
testpaths = ["test"]
testpaths = ["test"]

25
scripts/add-utxo.py

@ -5,12 +5,17 @@ users to retry transactions more often without getting banned by
the anti-snooping feature employed by makers.
"""
import asyncio
import sys
import os
import json
from pprint import pformat
from optparse import OptionParser
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmclient import load_program_config, jm_single,\
open_wallet, WalletService, add_external_commitments, update_commitments,\
PoDLE, get_podle_commitments, get_utxo_info, validate_utxo_data, quit,\
@ -48,7 +53,7 @@ def add_ext_commitments(utxo_datas):
ecs[u]['reveal'][j] = {'P2':P2, 's':s, 'e':e}
add_external_commitments(ecs)
def main():
async def main():
parser = OptionParser(
usage=
'usage: %prog [options] [txid:n]',
@ -171,7 +176,7 @@ def main():
#csv file or json file.
if options.loadwallet:
wallet_path = get_wallet_path(options.loadwallet)
wallet = open_wallet(wallet_path, gap_limit=options.gaplimit)
wallet = await open_wallet(wallet_path, gap_limit=options.gaplimit)
wallet_service = WalletService(wallet)
if wallet_service.rpc_error:
sys.exit(EXIT_FAILURE)
@ -182,7 +187,8 @@ def main():
# minor note: adding a utxo from an external wallet for commitments, we
# default to not allowing disabled utxos to avoid a privacy leak, so the
# user would have to explicitly enable.
for md, utxos in wallet_service.get_utxos_by_mixdepth().items():
_utxos = await wallet_service.get_utxos_by_mixdepth()
for md, utxos in _utxos.items():
for utxo, utxodata in utxos.items():
wif = wallet_service.get_wif_path(utxodata['path'])
utxo_data.append((utxo, wif))
@ -245,6 +251,15 @@ def main():
assert len(utxo_data)
add_ext_commitments(utxo_data)
if __name__ == "__main__":
main()
@wrap_main
async def _main():
await main()
jmprint('done', "success")
if __name__ == "__main__":
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

56
scripts/bdecode.py

@ -0,0 +1,56 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import click
import json
from pprint import pprint
from fastbencode import bdecode
def debyte_list(lst):
res = []
for item in lst:
if isinstance(item, bytes):
item = item.decode('ISO-8859-1')
elif isinstance(item, list):
item = debyte_list(item)
res.append(item)
return res
def debyte_dict(d):
res = {}
for k, v in d.items():
if isinstance(k, bytes):
k = k.decode('ISO-8859-1')
if isinstance(v, dict):
v = debyte_dict(v)
elif isinstance(v, bytes):
v = v.decode('ISO-8859-1')
elif isinstance(v, list):
v = debyte_list(v)
res[k] = v
return res
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
@click.command(context_settings=CONTEXT_SETTINGS)
@click.option('-i', '--input-file', required=True,
help='Input file')
@click.option('-n', '--no-decode', is_flag=True, default=False,
help='Do not decode to ISO-8859-1')
def main(**kwargs):
input_file = kwargs.pop('input_file')
no_decode = kwargs.pop('no_decode')
with open(input_file, 'rb') as fd:
data = fd.read()
if no_decode:
d = bdecode(data[8:])
pprint(d)
else:
d = debyte_dict(bdecode(data[8:]))
print(json.dumps(d, indent=4))
if __name__ == '__main__':
main()

68
scripts/bencode.py

@ -0,0 +1,68 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import click
import json
from fastbencode import bencode_utf8
def enbyte_list(lst):
res = []
for item in lst:
if isinstance(item, str):
item = item.encode('ISO-8859-1')
elif isinstance(item, list):
item = enbyte_list(item)
res.append(item)
return res
def enbyte_dict(d):
res = {}
for k, v in d.items():
if isinstance(k, str):
k = k.encode('ISO-8859-1')
if isinstance(v, dict):
v = enbyte_dict(v)
elif isinstance(v, str):
v = v.encode('ISO-8859-1')
elif isinstance(v, list):
v = enbyte_list(v)
res[k] = v
return res
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
@click.command(context_settings=CONTEXT_SETTINGS)
@click.option('-i', '--input-file', required=True,
help='Unencoded file')
@click.option('-o', '--output-file', required=True,
help='Output file')
@click.option('-d', '--dkg-magic', is_flag=True, default=False,
help='Prepend dkg storage magic')
@click.option('-r', '--recovery-magic', is_flag=True, default=False,
help='Prepend recovery storage magic')
def main(**kwargs):
input_file = kwargs.pop('input_file')
output_file = kwargs.pop('output_file')
dkg_magic = kwargs.pop('dkg_magic')
recovery_magic = kwargs.pop('recovery_magic')
if dkg_magic and recovery_magic:
raise click.UsageError('Options -d and -r is mutually exclusive')
if dkg_magic:
MAGIC_UNENC = b'JMDKGDAT'
elif recovery_magic:
MAGIC_UNENC = b'JMDKGREC'
else:
MAGIC_UNENC = b'JMWALLET'
with open(input_file, 'r') as fd:
data = json.loads(fd.read())
data = enbyte_dict(data)
with open(output_file, 'wb') as wfd:
wfd.write(MAGIC_UNENC + bencode_utf8(data))
if __name__ == '__main__':
main()

18
scripts/bond-calculator.py

@ -1,10 +1,16 @@
#!/usr/bin/env python3
import asyncio
import sys
from datetime import datetime
from decimal import Decimal
from json import loads
from optparse import OptionParser
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmbase import EXIT_ARGERROR, jmprint, get_log, utxostr_to_utxo, EXIT_FAILURE
from jmbitcoin import amount_to_sat, amount_to_str
from jmclient import add_base_options, load_program_config, jm_single, get_bond_values
@ -24,7 +30,7 @@ with the fidelity bonds in the orderbook.
log = get_log()
def main() -> None:
async def main() -> None:
parser = OptionParser(
usage="usage: %prog [options] UTXO or amount",
description=DESCRIPTION,
@ -128,5 +134,13 @@ def main() -> None:
jmprint(f"Top {result['percentile']}% of the orderbook by value")
@wrap_main
async def _main():
await main()
if __name__ == "__main__":
main()
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

36
scripts/bumpfee.py

@ -1,6 +1,12 @@
#!/usr/bin/env python3
import asyncio
from decimal import Decimal
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmbase import get_log, hextobin, bintohex
from jmbase.support import EXIT_SUCCESS, EXIT_FAILURE, EXIT_ARGERROR, jmprint, cli_prompt_user_yesno
from jmclient import jm_single, load_program_config, open_test_wallet_maybe, get_wallet_path, WalletService
@ -130,17 +136,18 @@ def prepare_transaction(new_tx, old_tx, wallet):
return (input_scripts, spent_outs)
def sign_transaction(new_tx, old_tx, wallet_service):
async def sign_transaction(new_tx, old_tx, wallet_service):
input_scripts, _ = prepare_transaction(new_tx, old_tx, wallet_service.wallet)
success, msg = wallet_service.sign_tx(new_tx, input_scripts)
success, msg = await wallet_service.sign_tx(new_tx, input_scripts)
if not success:
raise RuntimeError("Failed to sign transaction, quitting. Error msg: " + msg)
def sign_psbt(new_tx, old_tx, wallet_service):
async def sign_psbt(new_tx, old_tx, wallet_service):
_, spent_outs = prepare_transaction(new_tx, old_tx, wallet_service.wallet)
unsigned_psbt = wallet_service.create_psbt_from_tx(
unsigned_psbt = await wallet_service.create_psbt_from_tx(
new_tx, spent_outs=spent_outs)
signed_psbt, err = wallet_service.sign_psbt(unsigned_psbt.serialize())
signed_psbt, err = await wallet_service.sign_psbt(
unsigned_psbt.serialize())
if err:
raise RuntimeError("Failed to sign PSBT, quitting. Error message: " + err)
@ -199,7 +206,7 @@ def create_bumped_tx(tx, fee_per_kb, wallet, output_index=-1):
tx.vin, tx.vout, nLockTime=tx.nLockTime,
nVersion=tx.nVersion)
if __name__ == '__main__':
async def main(self):
(options, args) = parser.parse_args()
load_program_config(config_path=options.datadir)
if len(args) < 2:
@ -221,7 +228,7 @@ if __name__ == '__main__':
# open the wallet and synchronize it
wallet_path = get_wallet_path(wallet_name, None)
wallet = open_test_wallet_maybe(
wallet = await open_test_wallet_maybe(
wallet_path, wallet_name, options.amtmixdepths - 1,
wallet_password_stdin=options.wallet_password_stdin,
gap_limit=options.gaplimit)
@ -250,7 +257,7 @@ if __name__ == '__main__':
# sign the transaction
if options.with_psbt:
try:
psbt = sign_psbt(bumped_tx, orig_tx, wallet_service)
psbt = await sign_psbt(bumped_tx, orig_tx, wallet_service)
print("Completed PSBT created: ")
print(wallet_service.human_readable_psbt(psbt))
@ -263,7 +270,7 @@ if __name__ == '__main__':
sys.exit(EXIT_FAILURE)
else:
try:
sign_transaction(bumped_tx, orig_tx, wallet_service)
await sign_transaction(bumped_tx, orig_tx, wallet_service)
except RuntimeError as e:
jlog.error(str(e))
sys.exit(EXIT_FAILURE)
@ -284,3 +291,14 @@ if __name__ == '__main__':
jlog.error("Transaction broadcast failed!")
sys.exit(EXIT_FAILURE)
@wrap_main
async def _main():
await main()
if __name__ == "__main__":
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

23
scripts/genwallet.py

@ -3,8 +3,14 @@
# A script for noninteractively creating wallets.
# The implementation is similar to wallet_generate_recover_bip39 in jmclient/wallet_utils.py
import asyncio
import os
from optparse import OptionParser
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from pathlib import Path
from jmclient import (
load_program_config, add_base_options, SegwitWalletFidelityBonds, SegwitLegacyWallet,
@ -14,7 +20,7 @@ from jmbase.support import get_log, jmprint
log = get_log()
def main():
async def main():
parser = OptionParser(
usage='usage: %prog [options] wallet_file_name [password]',
description='Create a wallet with the given wallet name and password.'
@ -45,10 +51,21 @@ def main():
# Fidelity Bonds are not available for segwit legacy wallets
walletclass = SegwitLegacyWallet
entropy = seed and SegwitLegacyWallet.entropy_from_mnemonic(seed)
wallet = create_wallet(wallet_path, password, wallet_utils.DEFAULT_MIXDEPTH, walletclass, entropy=entropy)
wallet = await create_wallet(
wallet_path, password, wallet_utils.DEFAULT_MIXDEPTH,
walletclass, entropy=entropy)
jmprint("recovery_seed:{}"
.format(wallet.get_mnemonic_words()[0]), "important")
wallet.close()
@wrap_main
async def _main():
await main()
if __name__ == "__main__":
main()
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

1352
scripts/joinmarket-qt.py

File diff suppressed because it is too large Load Diff

27
scripts/obwatch/ob-watcher.py

@ -11,7 +11,7 @@ import os
import threading
import time
import sys
from datetime import datetime, timedelta
import datetime
from decimal import Decimal
from optparse import OptionParser
from typing import Tuple, Union
@ -53,7 +53,8 @@ bond_exponent = None
#Initial state: allow only SW offer types
sw0offers = list(filter(lambda x: x[0:3] == 'sw0', offername_list))
swoffers = list(filter(lambda x: x[0:3] == 'swa' or x[0:3] == 'swr', offername_list))
filtered_offername_list = sw0offers
troffers = list(filter(lambda x: x[0:3] == 'tra' or x[0:3] == 'trr', offername_list))
filtered_offername_list = troffers # FIXME allow selection of offers types
rotateObform = '<form action="rotateOb" method="post"><input type="submit" value="Rotate orderbooks"/></form>'
refresh_orderbook_form = '<form action="refreshorderbook" method="post"><input type="submit" value="Check for timed-out counterparties" /></form>'
@ -80,7 +81,8 @@ def do_nothing(arg, order, btc_unit, rel_unit):
def ordertype_display(ordertype, order, btc_unit, rel_unit):
ordertypes = {'sw0absoffer': 'Native SW Absolute Fee', 'sw0reloffer': 'Native SW Relative Fee',
'swabsoffer': 'SW Absolute Fee', 'swreloffer': 'SW Relative Fee'}
'swabsoffer': 'SW Absolute Fee', 'swreloffer': 'SW Relative Fee',
'trabsoffer': 'Taproot Absolute Fee', 'trreloffer': 'Taproot Relative Fee'}
return ordertypes[ordertype]
@ -88,13 +90,14 @@ def cjfee_display(cjfee: Union[Decimal, float, int],
order: dict,
btc_unit: str,
rel_unit: str) -> str:
if order['ordertype'] in ['swabsoffer', 'sw0absoffer']:
if order['ordertype'] in ['trabsoffer', 'swabsoffer', 'sw0absoffer']:
val = sat_to_unit(cjfee, html.unescape(btc_unit))
if btc_unit == "BTC":
return "%.8f" % val
else:
return str(val)
elif order['ordertype'] in ['reloffer', 'swreloffer', 'sw0reloffer']:
elif order['ordertype'] in ['trreloffer', 'reloffer', 'swreloffer',
'sw0reloffer']:
return str(Decimal(cjfee) * Decimal(rel_unit_to_factor[rel_unit])) + rel_unit
@ -245,8 +248,8 @@ class OrderbookPageRequestHeader(http.server.SimpleHTTPRequestHandler):
for row in rows:
o = dict(row)
if 'cjfee' in o:
if o['ordertype'] == 'swabsoffer'\
or o['ordertype'] == 'sw0absoffer':
if o['ordertype'] in ['trabsoffer', 'swabsoffer',
'sw0absoffer']:
o['cjfee'] = int(o['cjfee'])
else:
o['cjfee'] = str(Decimal(o['cjfee']))
@ -368,13 +371,19 @@ class OrderbookPageRequestHeader(http.server.SimpleHTTPRequestHandler):
bond_value_str = bond_value_to_str(sat_to_unit_power(bond_value,
2 * bitcoin_unit_to_power(html.unescape(btc_unit))),
html.unescape(btc_unit))
conf_time_str = str(datetime.utcfromtimestamp(0) + timedelta(seconds=conf_time))
conf_time_str = (
datetime.datetime.fromtimestamp(0, datetime.UTC) +
datetime.timedelta(seconds=conf_time)
).strftime('%Y-%m-%d %H:%M:%S')
utxo_value_str = sat_to_unit(utxo_data["value"], html.unescape(btc_unit))
bondtable += ("<tr>"
+ elem(bond_data.maker_nick)
+ elem(bintohex(bond_data.utxo[0]) + ":" + str(bond_data.utxo[1]))
+ elem(bond_value_str)
+ elem((datetime.utcfromtimestamp(0) + timedelta(seconds=bond_data.locktime)).strftime("%Y-%m-%d"))
+ elem((
datetime.datetime.fromtimestamp(0, datetime.UTC) +
datetime.timedelta(seconds=bond_data.locktime)
).strftime("%Y-%m-%d"))
+ elem(utxo_value_str)
+ elem(conf_time_str)
+ elem(str(bond_data.cert_expiry*RETARGET_INTERVAL))

303
scripts/qtsupport.py

@ -17,12 +17,13 @@ Qt files for the wizard for initiating a tumbler run.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
'''
import asyncio
import math, logging, qrcode, re, string
from io import BytesIO
from PySide2 import QtCore
from PySide6 import QtCore
from PySide2.QtGui import *
from PySide2.QtWidgets import *
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from bitcointx.core import satoshi_to_coins
from jmbitcoin.amount import amount_to_sat, btc_to_sat, sat_to_str
@ -62,7 +63,7 @@ config_types = {'rpc_port': int,
'absurd_fee_per_kb': 'amount'}
config_tips = {
'blockchain_source': 'options: bitcoin-rpc, regtest (for testing)',
'network': 'one of "signet", "testnet" or "mainnet"',
'network': 'one of "signet", "testnet", "testnet4" or "mainnet"',
'checktx': 'whether to check fees before completing transaction',
'rpc_host':
'the host for bitcoind; only used if blockchain_source is bitcoin-rpc',
@ -147,50 +148,112 @@ donation_more_message = '\n'.join(
'is no change output that can be linked with your inputs later.'])
"""
def JMQtMessageBox(obj, msg, mbtype='info', title='', detailed_text= None):
mbtypes = {'info': QMessageBox.information,
'crit': QMessageBox.critical,
'warn': QMessageBox.warning,
'question': QMessageBox.question}
async def JMInputDialog(parent, title, label, echo_mode=QLineEdit.Normal,
text='', finished_cb=None):
title = "JoinmarketQt - " + title
class QtInputDialog(QInputDialog):
def __init__(self, parent):
QInputDialog.__init__(self, parent)
self.result_fut = asyncio.get_event_loop().create_future()
self.setModal(True)
@QtCore.Slot(QMessageBox.StandardButton)
def on_finished(self, button):
self.result_fut.set_result(button)
async def result(self):
await self.result_fut
return self.result_fut.result()
d = QtInputDialog(parent)
d.setWindowTitle(title)
d.setLabelText(label)
d.setTextEchoMode(echo_mode)
d.setTextValue(text)
d.finished.connect(d.on_finished)
d.open()
result = await d.result()
if finished_cb is not None:
finished_cb(result)
if result != QDialog.Accepted:
return '', False
return d.textValue(), True
async def JMQtMessageBox(parent, msg, mbtype='info', title='',
detailed_text=None, informative_text=None,
finished_cb=None):
title = "JoinmarketQt - " + title
class JMQtDMessageBox(QMessageBox):
def __init__(self, parent):
QMessageBox.__init__(self, parent=parent)
self.result_fut = asyncio.get_event_loop().create_future()
self.setSizeGripEnabled(True)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.layout().setSizeConstraint(QLayout.SetMaximumSize)
self.setModal(True)
def resizeEvent(self, event):
self.setMinimumHeight(0)
self.setMaximumHeight(16777215)
self.setMinimumWidth(0)
self.setMaximumWidth(16777215)
result = super().resizeEvent(event)
if detailed_text:
assert mbtype == 'info'
details_box = self.findChild(QTextEdit)
if details_box is not None:
details_box.setMinimumHeight(0)
details_box.setMaximumHeight(16777215)
details_box.setMinimumWidth(0)
details_box.setMaximumWidth(16777215)
details_box.setSizePolicy(QSizePolicy.Expanding,
QSizePolicy.Expanding)
return result
@QtCore.Slot(QMessageBox.StandardButton)
def on_finished(self, button):
self.result_fut.set_result(button)
async def result(self):
await self.result_fut
return self.result_fut.result()
mb = JMQtDMessageBox(parent)
if mbtype == 'question':
return QMessageBox.question(obj, title, msg, QMessageBox.Yes,
QMessageBox.No)
icon = QMessageBox.Question
elif mbtype == 'warn':
icon = QMessageBox.Warning
elif mbtype == 'crit':
icon = QMessageBox.Critical
else:
if detailed_text:
assert mbtype == 'info'
class JMQtDMessageBox(QMessageBox):
def __init__(self):
QMessageBox.__init__(self)
self.setSizeGripEnabled(True)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.layout().setSizeConstraint(QLayout.SetMaximumSize)
def resizeEvent(self, event):
self.setMinimumHeight(0)
self.setMaximumHeight(16777215)
self.setMinimumWidth(0)
self.setMaximumWidth(16777215)
result = super().resizeEvent(event)
details_box = self.findChild(QTextEdit)
if details_box is not None:
details_box.setMinimumHeight(0)
details_box.setMaximumHeight(16777215)
details_box.setMinimumWidth(0)
details_box.setMaximumWidth(16777215)
details_box.setSizePolicy(QSizePolicy.Expanding,
QSizePolicy.Expanding)
return result
b = JMQtDMessageBox()
b.setIcon(QMessageBox.Information)
b.setWindowTitle(title)
b.setText(msg)
b.setDetailedText(detailed_text)
b.setStandardButtons(QMessageBox.Ok)
retval = b.exec_()
else:
mbtypes[mbtype](obj, title, msg)
icon = QMessageBox.Information
mb.setIcon(icon)
mb.setWindowTitle(title)
mb.setText(msg)
if detailed_text:
mb.setDetailedText(detailed_text)
if informative_text:
mb.setInformativeText(informative_text)
if mbtype == 'question':
mb.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
mb.setDefaultButton(QMessageBox.No)
else:
mb.setStandardButtons(QMessageBox.Ok)
mb.setDefaultButton(QMessageBox.NoButton)
mb.finished.connect(mb.on_finished)
mb.open()
result = await mb.result()
if finished_cb is not None:
finished_cb(result)
return result
class QtHandler(logging.Handler):
@ -336,8 +399,8 @@ def make_password_dialog(self, msg):
grid.setColumnStretch(1, 1)
#TODO perhaps add an icon here
logo = QLabel()
lockfile = ":icons/lock.png"
logo.setPixmap(QPixmap(lockfile).scaledToWidth(36))
lock_icon = QIcon.fromTheme(QIcon.ThemeIcon.DialogPassword)
logo.setPixmap(lock_icon.pixmap(QtCore.QSize(48, 48)))
logo.setAlignment(QtCore.Qt.AlignCenter)
grid.addWidget(logo, 0, 0)
@ -367,17 +430,91 @@ def make_password_dialog(self, msg):
return vbox
class PasswordDialog(QDialog):
class JMExportPrivkeysDialog(QDialog):
def __init__(self):
super().__init__()
def __init__(self, parent=None):
super().__init__(parent=parent)
self.result_fut = asyncio.get_event_loop().create_future()
self.initUI()
self.setModal(True)
def initUI(self):
self.setWindowTitle('Create a new passphrase')
msg = "Enter a new passphrase"
self.setLayout(make_password_dialog(self, msg))
self.show()
self.setWindowTitle('Private keys')
self.setMinimumSize(850, 300)
vbox = QVBoxLayout(self)
msg = "%s\n%s\n%s" % (
"WARNING: ALL your private keys are secret.",
"Exposing a single private key can compromise your entire wallet!",
"In particular, DO NOT use 'redeem private key' services proposed by third parties."
)
vbox.addWidget(QLabel(msg))
self.e = QTextEdit()
self.e.setReadOnly(True)
vbox.addWidget(self.e)
self.b = OkButton(self, 'Export')
self.b.setEnabled(False)
vbox.addLayout(Buttons(CancelButton(self), self.b))
@QtCore.Slot(QMessageBox.StandardButton)
def on_finished(self, result):
self.result_fut.set_result(result)
async def result(self):
await self.result_fut
return self.result_fut.result()
async def JMPasswordDialog(parent):
class PasswordDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent=parent)
self.result_fut = asyncio.get_event_loop().create_future()
self.initUI()
self.setModal(True)
def initUI(self):
self.setWindowTitle('Create a new passphrase')
msg = "Enter a new passphrase"
self.setLayout(make_password_dialog(self, msg))
self.show()
@QtCore.Slot(QMessageBox.StandardButton)
def on_finished(self, result):
self.result_fut.set_result(result)
async def result(self):
await self.result_fut
return self.result_fut.result()
while True:
pd = PasswordDialog(parent=parent)
pd.finished.connect(pd.on_finished)
for child in pd.findChildren(QLineEdit):
child.clear()
pd.findChild(QLineEdit).setFocus()
pd.open()
pd_return = await pd.result()
if pd_return == QDialog.Rejected:
return None
elif pd.new_pw.text() != pd.conf_pw.text():
await JMQtMessageBox(parent,
"Passphrases don't match.",
mbtype='warn',
title="Error")
continue
elif pd.new_pw.text() == "":
await JMQtMessageBox(parent,
"Passphrase must not be empty.",
mbtype='warn',
title="Error")
continue
break
textpassword = str(pd.new_pw.text())
return textpassword
class MyTreeWidget(QTreeWidget):
@ -815,20 +952,31 @@ class SchIntroPage(QWizardPage):
"""
class ScheduleWizard(QWizard):
def __init__(self):
super().__init__()
def __init__(self, parent, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self.result_fut = asyncio.get_event_loop().create_future()
self.finished.connect(self.on_finished)
self.setWindowTitle("Joinmarket schedule generator")
self.setPage(0, SchIntroPage(self))
self.setPage(1, SchDynamicPage1(self))
self.setPage(2, SchDynamicPage2(self))
#self.setPage(3, SchStaticPage(self))
self.setPage(3, SchFinishPage(self))
self.setModal(True)
@QtCore.Slot(QMessageBox.StandardButton)
def on_finished(self, button):
self.result_fut.set_result(button)
async def result(self):
await self.result_fut
return self.result_fut.result()
def get_name(self):
#TODO de-hardcode generated name
return "TUMBLE.schedule"
def get_destaddrs(self):
return self.destaddrs
@ -839,8 +987,9 @@ class ScheduleWizard(QWizard):
if validate_address(daddrstring)[0]:
self.destaddrs.append(daddrstring)
elif daddrstring != "":
JMQtMessageBox(self, "Error, invalid address", mbtype='crit',
title='Error')
asyncio.ensure_future(
JMQtMessageBox(self, "Error, invalid address",
mbtype='crit', title='Error'))
return None
self.opts = {}
self.opts['mixdepthcount'] = int(self.field("mixdepthcount"))
@ -863,11 +1012,24 @@ class ScheduleWizard(QWizard):
return get_tumble_schedule(self.opts, self.destaddrs,
wallet_balance_by_mixdepth, max_mixdepth_in_wallet)
class TumbleRestartWizard(QWizard):
def __init__(self):
super().__init__()
def __init__(self, parent, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self.result_fut = asyncio.get_event_loop().create_future()
self.finished.connect(self.on_finished)
self.setWindowTitle("Restart tumbler schedule")
self.setPage(0, RestartSettingsPage(self))
self.setModal(True)
@QtCore.Slot(QMessageBox.StandardButton)
def on_finished(self, button):
self.result_fut.set_result(button)
async def result(self):
await self.result_fut
return self.result_fut.result()
def getOptions(self):
self.opts = {}
@ -879,6 +1041,7 @@ class TumbleRestartWizard(QWizard):
jm_single().mincjamount = self.opts['mincjamount']
return self.opts
class RestartSettingsPage(QWizardPage):
def __init__(self, parent):
@ -939,8 +1102,9 @@ class CopyOnClickLineEdit(QLineEdit):
self.selectAll()
self.copy()
if not self.was_copied:
JMQtMessageBox(self,
"URI copied to clipboard", mbtype="info")
asyncio.ensure_future(
JMQtMessageBox(self,
"URI copied to clipboard", mbtype="info"))
self.was_copied = True
@ -976,14 +1140,14 @@ class ReceiveBIP78Dialog(QDialog):
parameter_types = ["btc", int]
parameter_settings = ["", 0]
def __init__(self, action_fn, cancel_fn, parameter_settings=None):
def __init__(self, parent, action_fn, cancel_fn, parameter_settings=None):
""" Parameter action_fn:
each time the user opens the dialog they will
pass a function to be connected to the action-button.
Signature: no arguments, return value False if action initiation
is aborted, otherwise True.
"""
super().__init__()
super().__init__(parent)
if parameter_settings:
self.parameter_settings = parameter_settings
# these QLineEdit or QLabel objects will contain the
@ -994,9 +1158,9 @@ class ReceiveBIP78Dialog(QDialog):
self.cancel_fn = cancel_fn
self.updates_final = False
self.initUI()
self.setModal(True)
def initUI(self):
self.setModal(1)
self.setWindowTitle("Receive Payjoin")
self.setLayout(self.get_receive_bip78_dialog())
self.show()
@ -1040,7 +1204,7 @@ class ReceiveBIP78Dialog(QDialog):
self.qr_btn.setVisible(False)
self.btnbox.button(QDialogButtonBox.Cancel).setDisabled(True)
def start_generate(self):
async def start_generate(self):
""" Before starting up the
hidden service and initiating the payment
workflow, disallow starting again; user
@ -1049,7 +1213,7 @@ class ReceiveBIP78Dialog(QDialog):
aborted, we reset the generate button.
"""
self.generate_btn.setDisabled(True)
if not self.action_fn():
if not await self.action_fn():
self.generate_btn.setDisabled(False)
def get_receive_bip78_dialog(self):
@ -1110,7 +1274,8 @@ class ReceiveBIP78Dialog(QDialog):
# it is also associated with 'rejection' (and we don't use "OK" because
# concept doesn't quite fit here:
self.btnbox.rejected.connect(self.shutdown_actions)
self.generate_btn.clicked.connect(self.start_generate)
self.generate_btn.clicked.connect(
lambda: asyncio.create_task(self.start_generate()))
self.qr_btn.clicked.connect(self.open_qr_code_popup)
# does not trigger cancel_fn callback:
self.close_btn.clicked.connect(self.close)

25
scripts/receive-payjoin.py

@ -1,9 +1,14 @@
#!/usr/bin/env python3
import asyncio
from optparse import OptionParser
import sys
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmbase import get_log, jmprint
from jmclient import jm_single, load_program_config, \
WalletService, open_test_wallet_maybe, get_wallet_path, check_regtest, \
@ -13,7 +18,8 @@ from jmbase.support import EXIT_FAILURE, EXIT_ARGERROR
from jmbitcoin import amount_to_sat
jlog = get_log()
def receive_payjoin_main():
async def receive_payjoin_main():
parser = OptionParser(usage='usage: %prog [options] [wallet file] [amount-to-receive]')
add_base_options(parser)
parser.add_option('-P', '--hs-port', action='store', type='int',
@ -55,7 +61,7 @@ def receive_payjoin_main():
wallet_path = get_wallet_path(wallet_name, None)
max_mix_depth = max([options.mixdepth, options.amtmixdepths - 1])
wallet = open_test_wallet_maybe(
wallet = await open_test_wallet_maybe(
wallet_path, wallet_name, max_mix_depth,
wallet_password_stdin=options.wallet_password_stdin,
gap_limit=options.gaplimit)
@ -72,6 +78,8 @@ def receive_payjoin_main():
sys.exit(EXIT_ARGERROR)
receiver_manager = JMBIP78ReceiverManager(wallet_service, options.mixdepth,
bip78_amount, options.hsport)
await receiver_manager.async_init(wallet_service, options.mixdepth,
bip78_amount)
reactor.callWhenRunning(receiver_manager.initiate)
nodaemon = jm_single().config.getint("DAEMON", "no_daemon")
daemon = True if nodaemon == 1 else False
@ -80,6 +88,15 @@ def receive_payjoin_main():
# JM is default, so must be switched off explicitly in this call:
start_reactor(dhost, dport, bip78=True, jm_coinjoin=False, daemon=daemon)
if __name__ == "__main__":
receive_payjoin_main()
@wrap_main
async def _main():
await receive_payjoin_main()
jmprint('done')
if __name__ == "__main__":
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

78
scripts/sendpayment.py

@ -7,10 +7,14 @@ For notes, see scripts/README.md; in particular, note the use
of "schedules" with the -S flag.
"""
import asyncio
import sys
from twisted.internet import reactor
import pprint
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmclient import Taker, load_program_config, get_schedule,\
JMClientProtocolFactory, start_reactor, validate_address, is_burn_destination, \
jm_single, estimate_tx_fee, direct_send, WalletService,\
@ -18,7 +22,7 @@ from jmclient import Taker, load_program_config, get_schedule,\
get_sendpayment_parser, get_max_cj_fee_values, check_regtest, \
parse_payjoin_setup, send_payjoin, general_custom_change_warning, \
nonwallet_custom_change_warning, sweep_custom_change_warning, \
EngineError, check_and_start_tor
EngineError, check_and_start_tor, FrostWallet, FrostIPCClient
from twisted.python.log import startLogging
from jmbase.support import get_log, jmprint, \
EXIT_FAILURE, EXIT_ARGERROR, cli_prompt_user_yesno
@ -50,7 +54,7 @@ def pick_order(orders, n): #pragma: no cover
return orders[pickedOrderIndex]
pickedOrderIndex = -1
def main():
async def main():
parser = get_sendpayment_parser()
(options, args) = parser.parse_args()
load_program_config(config_path=options.datadir)
@ -182,7 +186,7 @@ def main():
maxcjfee = (1, float('inf'))
if not options.pickorders and options.makercount != 0:
maxcjfee = get_max_cj_fee_values(jm_single().config, options)
maxcjfee = await get_max_cj_fee_values(jm_single().config, options)
log.info("Using maximum coinjoin fee limits per maker of {:.4%}, {} "
"".format(maxcjfee[0], btc.amount_to_str(maxcjfee[1])))
@ -191,17 +195,21 @@ def main():
max_mix_depth = max([mixdepth, options.amtmixdepths - 1])
wallet_path = get_wallet_path(wallet_name, None)
wallet = open_test_wallet_maybe(
wallet = await open_test_wallet_maybe(
wallet_path, wallet_name, max_mix_depth,
wallet_password_stdin=options.wallet_password_stdin,
gap_limit=options.gaplimit)
wallet_service = WalletService(wallet)
if wallet_service.rpc_error:
sys.exit(EXIT_FAILURE)
if isinstance(wallet, FrostWallet):
ipc_client = FrostIPCClient(wallet)
await ipc_client.async_init()
wallet.set_ipc_client(ipc_client)
# in this script, we need the wallet synced before
# logic processing for some paths, so do it now:
while not wallet_service.synced:
wallet_service.sync_wallet(fast=not options.recoversync)
await wallet_service.sync_wallet(fast=not options.recoversync)
# the sync call here will now be a no-op:
wallet_service.startService()
@ -270,13 +278,13 @@ def main():
sys.exit(EXIT_ARGERROR)
if options.makercount == 0 and not bip78url:
tx = direct_send(wallet_service, mixdepth,
[(destaddr, amount)],
options.answeryes,
with_final_psbt=options.with_psbt,
optin_rbf=not options.no_rbf,
custom_change_addr=custom_change,
change_label=options.changelabel)
tx = await direct_send(wallet_service, mixdepth,
[(destaddr, amount)],
options.answeryes,
with_final_psbt=options.with_psbt,
optin_rbf=not options.no_rbf,
custom_change_addr=custom_change,
change_label=options.changelabel)
if options.with_psbt:
log.info("This PSBT is fully signed and can be sent externally for "
"broadcasting:")
@ -309,11 +317,14 @@ def main():
return False
return True
asyncio_loop = asyncio.get_event_loop()
taker_finished_future = asyncio_loop.create_future()
def taker_finished(res, fromtx=False, waittime=0.0, txdetails=None):
if fromtx == "unconfirmed":
#If final entry, stop *here*, don't wait for confirmation
if taker.schedule_index + 1 == len(taker.schedule):
reactor.stop()
taker_finished_future.set_result(True)
return
if fromtx:
if res:
@ -334,7 +345,7 @@ def main():
#can only happen with < minimum_makers; see above.
log.info("A transaction failed but there are insufficient "
"honest respondants to continue; giving up.")
reactor.stop()
taker_finished_future.set_result(False)
return
#This is Phase 2; do we have enough to try again?
taker.add_honest_makers(list(set(
@ -344,7 +355,7 @@ def main():
"POLICY", "minimum_makers"):
log.info("Too few makers responded honestly; "
"giving up this attempt.")
reactor.stop()
taker_finished_future.set_result(False)
return
jmprint("We failed to complete the transaction. The following "
"makers responded honestly: " + str(taker.honest_makers) +\
@ -364,11 +375,12 @@ def main():
else:
if not res:
log.info("Did not complete successfully, shutting down")
taker_finished_future.set_result(False)
#Should usually be unreachable, unless conf received out of order;
#because we should stop on 'unconfirmed' for last (see above)
else:
log.info("All transactions completed correctly")
reactor.stop()
taker_finished_future.set_result(True)
nodaemon = jm_single().config.getint("DAEMON", "no_daemon")
daemon = True if nodaemon == 1 else False
@ -377,11 +389,21 @@ def main():
if bip78url:
# TODO sanity check wallet type is segwit
manager = parse_payjoin_setup(args[1], wallet_service, options.mixdepth)
reactor.callWhenRunning(send_payjoin, manager)
# JM is default, so must be switched off explicitly in this call:
start_reactor(dhost, dport, bip78=True, jm_coinjoin=False, daemon=daemon)
start_reactor(dhost, dport, bip78=True, jm_coinjoin=False,
daemon=daemon, gui=True)
wait_seconds = 180
while wait_seconds > 0:
if not reactor.running:
await asyncio.sleep(1)
wait_seconds -= 1
continue
break
if reactor.running:
await send_payjoin(manager)
else:
raise Exception("Reactor is not running for long time")
return
else:
taker = Taker(wallet_service,
schedule,
@ -394,8 +416,18 @@ def main():
if jm_single().config.get("BLOCKCHAIN", "network") == "regtest":
startLogging(sys.stdout)
start_reactor(dhost, dport, clientfactory, daemon=daemon)
start_reactor(dhost, dport, clientfactory, daemon=daemon, gui=True)
await taker_finished_future
if __name__ == "__main__":
main()
@wrap_main
async def _main():
await main()
jmprint('done', "success")
if __name__ == "__main__":
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

27
scripts/sendtomany.py

@ -5,8 +5,14 @@ for a Joinmarket user, although of course it may be useful
for other reasons).
"""
import asyncio
from optparse import OptionParser
import jmbitcoin as btc
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmbase import (get_log, jmprint, bintohex, utxostr_to_utxo,
IndentedHelpFormatterWithNL, cli_prompt_user_yesno)
from jmclient import load_program_config, estimate_tx_fee, jm_single,\
@ -64,7 +70,7 @@ p2wpkh ('bc1') addresses.
utxos - set segwit=False in the POLICY section of
joinmarket.cfg for the former."""
def main():
async def main():
parser = OptionParser(
usage=
'usage: %prog [options] utxo destaddr1 destaddr2 ..',
@ -74,7 +80,7 @@ def main():
'--utxo-address-type',
action='store',
dest='utxo_address_type',
help=('type of address of coin being spent - one of "p2pkh", "p2wpkh", "p2sh-p2wpkh". '
help=('type of address of coin being spent - one of "p2pkh", "p2wpkh", "p2sh-p2wpkh", "p2tr". '
'No other scriptpubkey types (e.g. multisig) are supported. If not set, we default '
'to what is in joinmarket.cfg.'),
default=""
@ -98,7 +104,9 @@ def main():
if not success:
quit(parser, "Failed to load utxo from string: " + utxo)
if options.utxo_address_type == "":
if jm_single().config.get("POLICY", "segwit") == "false":
if jm_single().config.get("POLICY", "taproot") == "true":
utxo_address_type = "p2tr"
elif jm_single().config.get("POLICY", "segwit") == "false":
utxo_address_type = "p2pkh"
elif jm_single().config.get("POLICY", "native") == "false":
utxo_address_type = "p2sh-p2wpkh"
@ -117,6 +125,15 @@ def main():
return
jm_single().bc_interface.pushtx(txsigned.serialize())
if __name__ == "__main__":
main()
@wrap_main
async def _main():
await main()
jmprint('done', "success")
if __name__ == "__main__":
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

33
scripts/snicker/create-snicker-proposal.py

@ -21,8 +21,14 @@ specified (see help for options), in which case the proposal is
output to stdout in the same string format: base64proposal,hexpubkey.
"""
import asyncio
import sys
from optparse import OptionParser
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmbase import bintohex, jmprint, hextobin, \
EXIT_ARGERROR, EXIT_FAILURE, EXIT_SUCCESS, get_pow
import jmbitcoin as btc
@ -35,7 +41,7 @@ from jmclient.configure import get_log
log = get_log()
def main():
async def main():
parser = OptionParser(
usage=
'usage: %prog [options] walletname hex-tx input-index output-index net-transfer',
@ -106,7 +112,7 @@ def main():
jm_single().config.set("POLICY", "tx_fees", str(options.txfee))
max_mix_depth = max([options.mixdepth, options.amtmixdepths - 1])
wallet_path = get_wallet_path(wallet_name, None)
wallet = open_test_wallet_maybe(
wallet = await open_test_wallet_maybe(
wallet_path, wallet_name, max_mix_depth,
wallet_password_stdin=options.wallet_password_stdin,
gap_limit=options.gaplimit)
@ -131,21 +137,21 @@ def main():
fee_est = estimate_tx_fee(2, 3, txtype=wallet_service.get_txtype())
amt_required = originating_tx.vout[output_index].nValue + fee_est
prop_utxo_dict = wallet_service.select_utxos(options.mixdepth,
prop_utxo_dict = await wallet_service.select_utxos(options.mixdepth,
amt_required)
prop_utxos = list(prop_utxo_dict)
prop_utxo_vals = [prop_utxo_dict[x] for x in prop_utxos]
# get the private key for that utxo
priv = wallet_service.get_key_from_addr(
wallet_service.script_to_addr(prop_utxo_vals[0]['script']))
await wallet_service.script_to_addr(prop_utxo_vals[0]['script']))
# construct the arguments for the snicker proposal:
our_input_utxos = [btc.CMutableTxOut(x['value'],
x['script']) for x in prop_utxo_vals]
# destination must be a different mixdepth:
prop_destn_spk = wallet_service.get_new_script((
prop_destn_spk = await wallet_service.get_new_script((
options.mixdepth + 1) % (wallet_service.mixdepth + 1), 1)
change_spk = wallet_service.get_new_script(options.mixdepth, 1)
change_spk = await wallet_service.get_new_script(options.mixdepth, 1)
their_input = (txid1, output_index)
# we also need to extract the pubkey of the chosen input from
# the witness; we vary this depending on our wallet type:
@ -153,7 +159,7 @@ def main():
if not pubkey:
log.error("Failed to extract pubkey from transaction: {}".format(msg))
sys.exit(EXIT_FAILURE)
encrypted_proposal = wallet_service.create_snicker_proposal(
encrypted_proposal = await wallet_service.create_snicker_proposal(
prop_utxos, their_input,
our_input_utxos,
originating_tx.vout[output_index],
@ -225,6 +231,15 @@ class SNICKERPostingClient(object):
self.proposals_with_nonce.append(preimage)
return self.proposals_with_nonce
if __name__ == "__main__":
main()
@wrap_main
async def _main():
await main()
jmprint('done', "success")
if __name__ == "__main__":
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

25
scripts/snicker/receive-snicker.py

@ -1,7 +1,13 @@
#!/usr/bin/env python3
import asyncio
from optparse import OptionParser
import sys
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmbase import get_log, jmprint
from jmclient import (jm_single, load_program_config, WalletService,
open_test_wallet_maybe, get_wallet_path,
@ -12,7 +18,7 @@ from jmbase.support import EXIT_ARGERROR
jlog = get_log()
def receive_snicker_main():
async def receive_snicker_main():
usage = """ Use this script to receive proposals for SNICKER
coinjoins, parse them and then broadcast coinjoins
that fit your criteria. See the SNICKER section of
@ -62,7 +68,7 @@ Usage: %prog [options] wallet file [proposal]
wallet_path = get_wallet_path(wallet_name, None)
max_mix_depth = max([options.mixdepth, options.amtmixdepths - 1])
wallet = open_test_wallet_maybe(
wallet = await open_test_wallet_maybe(
wallet_path, wallet_name, max_mix_depth,
wallet_password_stdin=options.wallet_password_stdin,
gap_limit=options.gaplimit)
@ -77,7 +83,7 @@ Usage: %prog [options] wallet file [proposal]
snicker_r = SNICKERReceiver(wallet_service)
if options.no_upload:
proposal = args[1]
snicker_r.process_proposals([proposal])
await snicker_r.process_proposals([proposal])
return
servers = jm_single().config.get("SNICKER", "servers").split(",")
snicker_pf = SNICKERClientProtocolFactory(snicker_r, servers, oneshot=True)
@ -86,6 +92,15 @@ Usage: %prog [options] wallet file [proposal]
None, snickerfactory=snicker_pf,
daemon=daemon)
if __name__ == "__main__":
receive_snicker_main()
@wrap_main
async def _main():
await receive_snicker_main()
jmprint('done')
if __name__ == "__main__":
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

22
scripts/snicker/snicker-finder.py

@ -24,8 +24,14 @@ in Bitcoin Core in order to get full transactions, since it
parses the raw blocks.
"""
import asyncio
import sys
from optparse import OptionParser
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmbase import bintohex, EXIT_ARGERROR, jmprint
import jmbitcoin as btc
from jmclient import (jm_single, add_base_options, load_program_config,
@ -49,7 +55,7 @@ def write_candidate_to_file(ttype, candidate, blocknum, unspents, filename):
"found in the above.\n")
f.write("The unspent indices are: " + " ".join(
(str(u) for u in unspents)) + "\n")
def main():
async def main():
parser = OptionParser(
usage=
'usage: %prog [options] startingblock [endingblock]',
@ -111,6 +117,16 @@ def main():
write_candidate_to_file("Joinmarket coinjoin", t, b,
unspents, options.candidate_file_name)
log.info("Finished processing block: {}".format(b))
if __name__ == "__main__":
main()
@wrap_main
async def _main():
await main()
jmprint('done', "success")
if __name__ == "__main__":
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

32
scripts/snicker/snicker-recovery.py

@ -24,8 +24,14 @@ keys, so as a reminder, *always* back up either jmdat wallet files,
or at least, the imported keys themselves.)
"""
import asyncio
import sys
from optparse import OptionParser
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmbase import bintohex, EXIT_ARGERROR, jmprint
import jmbitcoin as btc
from jmclient import (add_base_options, load_program_config,
@ -71,7 +77,7 @@ def get_pubs_and_indices_of_ancestor_inputs(txin, wallet_service, ours):
tx = wallet_service.get_transaction(txin.prevout.hash[::-1])
return get_pubs_and_indices_of_inputs(tx, wallet_service, ours=ours)
def main():
async def main():
parser = OptionParser(
usage=
'usage: %prog [options] walletname',
@ -104,7 +110,7 @@ def main():
wallet_name = args[0]
wallet_path = get_wallet_path(wallet_name, None)
max_mix_depth = max([options.mixdepth, options.amtmixdepths - 1])
wallet = open_test_wallet_maybe(
wallet = await open_test_wallet_maybe(
wallet_path, wallet_name, max_mix_depth,
wallet_password_stdin=options.wallet_password_stdin,
gap_limit=options.gaplimit)
@ -161,7 +167,7 @@ def main():
for (our_pub, j) in get_pubs_and_indices_of_ancestor_inputs(tx.vin[mi], wallet_service, ours=True):
our_spk = wallet_service.pubkey_to_script(our_pub)
our_priv = wallet_service.get_key_from_addr(
wallet_service.script_to_addr(our_spk))
await wallet_service.script_to_addr(our_spk))
tweak_bytes = btc.ecdh(our_priv[:-1], other_pub)
tweaked_pub = btc.snicker_pubkey_tweak(our_pub, tweak_bytes)
tweaked_spk = wallet_service.pubkey_to_script(tweaked_pub)
@ -169,7 +175,7 @@ def main():
# TODO wallet.script_to_addr has a dubious assertion, that's why
# we use btc method directly:
address_found = str(btc.CCoinAddress.from_scriptPubKey(btc.CScript(tweaked_spk)))
#address_found = wallet_service.script_to_addr(tweaked_spk)
#address_found = await wallet_service.script_to_addr(tweaked_spk)
jmprint("Found a new SNICKER output belonging to us.")
jmprint("Output address {} in the following transaction:".format(
address_found))
@ -178,8 +184,9 @@ def main():
# NB for a recovery we accept putting any imported keys all into
# the same mixdepth (0); TODO investigate correcting this, it will
# be a little complicated.
success, msg = wallet_service.check_tweak_matches_and_import(wallet_service.script_to_addr(our_spk),
tweak_bytes, tweaked_pub, wallet_service.mixdepth)
success, msg = await wallet_service.check_tweak_matches_and_import(
await wallet_service.script_to_addr(our_spk),
tweak_bytes, tweaked_pub, wallet_service.mixdepth)
if not success:
jmprint("Failed to import SNICKER key: {}".format(msg), "error")
return False
@ -199,9 +206,18 @@ def main():
"restarting this script.".format(earliest_new_blockheight))
return False
if __name__ == "__main__":
res = main()
@wrap_main
async def _main():
res = await main()
if not res:
jmprint("Script finished, recovery is NOT complete.", level="warning")
else:
jmprint("Script finished, recovery is complete.")
if __name__ == "__main__":
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

34
scripts/snicker/snicker-seed-tx.py

@ -19,9 +19,15 @@ this is a simulated coinjoin, it may be deducible that it is only really
a *signalling* fake coinjoin, so it is better not to violate the principle.
"""
import asyncio
import sys
import random
from optparse import OptionParser
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmbase import bintohex, jmprint, EXIT_ARGERROR, EXIT_FAILURE
import jmbitcoin as btc
from jmclient import (jm_single, load_program_config, check_regtest,
@ -32,7 +38,7 @@ from jmclient.configure import get_log
log = get_log()
def main():
async def main():
parser = OptionParser(
usage=
'usage: %prog [options] walletname',
@ -96,7 +102,7 @@ def main():
jm_single().config.set("POLICY", "tx_fees", str(options.txfee))
max_mix_depth = max([options.mixdepth, options.amtmixdepths - 1])
wallet_path = get_wallet_path(wallet_name, None)
wallet = open_test_wallet_maybe(
wallet = await open_test_wallet_maybe(
wallet_path, wallet_name, max_mix_depth,
wallet_password_stdin=options.wallet_password_stdin,
gap_limit=options.gaplimit)
@ -117,7 +123,8 @@ def main():
# *second* largest utxo as the receiver utxo; this ensures that we
# have enough for the proposer to cover. We consume utxos greedily,
# meaning we'll at least some of the time, be consolidating.
utxo_dict = wallet_service.get_utxos_by_mixdepth()[options.mixdepth]
_utxos = await wallet_service.get_utxos_by_mixdepth()
utxo_dict = _utxos[options.mixdepth]
if not len(utxo_dict) >= 2:
log.error("Cannot create fake SNICKER tx without at least two utxos, quitting")
sys.exit(EXIT_ARGERROR)
@ -158,8 +165,10 @@ def main():
# (not only in trivial output pattern, but also in subset-sum), there
# is little advantage in making it use different output mixdepths, so
# here to prevent fragmentation, everything is kept in the same mixdepth.
receiver_addr, proposer_addr, change_addr = (wallet_service.script_to_addr(
wallet_service.get_new_script(options.mixdepth, 1)) for _ in range(3))
receiver_addr, proposer_addr, change_addr = (
await wallet_service.script_to_addr(
await wallet_service.get_new_script(options.mixdepth, 1))
for _ in range(3))
# persist index update:
wallet_service.save_wallet()
outputs = btc.construct_snicker_outputs(
@ -188,7 +197,7 @@ def main():
script = utxo_dict[utxo]['script']
amount = utxo_dict[utxo]['value']
our_inputs[index] = (script, amount)
success, msg = wallet_service.sign_tx(tx, our_inputs)
success, msg = await wallet_service.sign_tx(tx, our_inputs)
if not success:
log.error("Failed to sign transaction: " + msg)
sys.exit(EXIT_FAILURE)
@ -205,6 +214,15 @@ def main():
log.info("Successfully broadcast fake SNICKER coinjoin: " +\
bintohex(tx.GetTxid()[::-1]))
if __name__ == "__main__":
main()
@wrap_main
async def _main():
await main()
jmprint('done', "success")
if __name__ == "__main__":
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

25
scripts/snicker/snicker-server.py

@ -22,7 +22,12 @@ arguments:
"""
import asyncio
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from twisted.internet.defer import Deferred
from twisted.web.server import Site
from twisted.web.resource import Resource
@ -34,6 +39,7 @@ import json
import sqlite3
import threading
from io import BytesIO
from jmbase import jmprint, hextobin, verify_pow
from jmclient import process_shutdown, jm_single, load_program_config, check_and_start_tor
from jmclient.configure import get_log
@ -153,8 +159,8 @@ class SNICKERServer(Resource):
bin_nonce = hextobin(nonce.decode('utf-8'))
base64.b64decode(encryptedtx)
except:
log.warn("This proposal was not accepted: " + proposal.decode(
"utf-8"))
log.warning("This proposal was not accepted: " +
proposal.decode("utf-8"))
# give up immediately in case of format error:
return self.return_error(request, "Invalid request format",
"invalid-request-format")
@ -179,8 +185,8 @@ class SNICKERServer(Resource):
self.cursor.execute('INSERT INTO {} VALUES(?, ?);'.format(
database_table_name),proposal_to_add)
except sqlite3.Error as e:
log.warn("Error inserting data into table: {}".format(
" ".join(e.args)))
log.warning("Error inserting data into table:"
" {}".format(" ".join(e.args)))
return False
self.conn.commit()
return True
@ -325,11 +331,14 @@ class SNICKERServerManager(object):
if self.shutdown_callback:
self.shutdown_callback()
def snicker_server_start(port, local_port=None, hsdir=None):
ssm = SNICKERServerManager(port, local_port=local_port, hsdir=hsdir)
ssm.start_snicker_server_and_tor()
if __name__ == "__main__":
@wrap_main
async def _main():
load_program_config(bs="no-blockchain")
check_and_start_tor()
# in testing, we can optionally use ephemeral;
@ -341,4 +350,10 @@ if __name__ == "__main__":
local_port = int(sys.argv[2])
hsdir = sys.argv[3]
snicker_server_start(port, local_port, hsdir)
# reactor.run()
if __name__ == "__main__":
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

31
scripts/tumbler.py

@ -1,7 +1,12 @@
#!/usr/bin/env python3
import asyncio
import sys
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
import os
import pprint
from twisted.python.log import startLogging
@ -11,7 +16,8 @@ from jmclient import Taker, load_program_config, get_schedule,\
schedule_to_text, estimate_tx_fee, restart_waiter, WalletService,\
get_tumble_log, tumbler_taker_finished_update, check_regtest, \
tumbler_filter_orders_callback, validate_address, get_tumbler_parser, \
get_max_cj_fee_values, get_total_tumble_amount, ScheduleGenerationErrorNoFunds
get_max_cj_fee_values, get_total_tumble_amount, \
ScheduleGenerationErrorNoFunds
from jmclient.wallet_utils import DEFAULT_MIXDEPTH
@ -20,7 +26,7 @@ from jmbase.support import get_log, jmprint, EXIT_SUCCESS, \
log = get_log()
def main():
async def main():
(options, args) = get_tumbler_parser().parse_args()
options_org = options
options = vars(options)
@ -28,8 +34,7 @@ def main():
jmprint('Error: Needs a wallet file', "error")
sys.exit(EXIT_ARGERROR)
load_program_config(config_path=options['datadir'])
logsdir = os.path.join(os.path.dirname(
jm_single().config_location), "logs")
logsdir = os.path.join(jm_single().datadir, "logs")
tumble_log = get_tumble_log(logsdir)
if jm_single().bc_interface is None:
@ -49,7 +54,8 @@ def main():
else:
max_mix_depth = DEFAULT_MIXDEPTH
wallet_path = get_wallet_path(wallet_name, None)
wallet = open_test_wallet_maybe(wallet_path, wallet_name, max_mix_depth,
wallet = await open_test_wallet_maybe(
wallet_path, wallet_name, max_mix_depth,
wallet_password_stdin=options_org.wallet_password_stdin)
wallet_service = WalletService(wallet)
if wallet_service.rpc_error:
@ -61,7 +67,7 @@ def main():
# the sync call here will now be a no-op:
wallet_service.startService()
maxcjfee = get_max_cj_fee_values(jm_single().config, options_org)
maxcjfee = await get_max_cj_fee_values(jm_single().config, options_org)
log.info("Using maximum coinjoin fee limits per maker of {:.4%}, {} sat"
.format(*maxcjfee))
@ -197,6 +203,15 @@ def main():
jm_single().config.getint("DAEMON", "daemon_port"),
clientfactory, daemon=daemon)
if __name__ == "__main__":
main()
@wrap_main
async def _main():
res = await main()
print('done')
if __name__ == "__main__":
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

22
scripts/wallet-tool.py

@ -1,6 +1,26 @@
#!/usr/bin/env python3
import asyncio
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmbase import jmprint
from jmclient import wallet_tool_main
@wrap_main
async def _main():
res = await wallet_tool_main("wallets")
if res:
jmprint(res, "success")
else:
jmprint("Finished", "success")
if __name__ == "__main__":
jmprint(wallet_tool_main("wallets"), "success")
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

27
scripts/yg-privacyenhanced.py

@ -1,10 +1,16 @@
#!/usr/bin/env python3
import asyncio
import random
import sys
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmbase import get_log, jmprint, EXIT_ARGERROR
from jmbitcoin import amount_to_str
from jmclient import YieldGeneratorBasic, ygmain, jm_single
from jmbitcoin import amount_to_str
# This is a maker for the purposes of generating a yield from held bitcoins
# while maximising the difficulty of spying on blockchain activity.
@ -14,8 +20,10 @@ from jmclient import YieldGeneratorBasic, ygmain, jm_single
# YIELD GENERATOR SETTINGS ARE NOW IN YOUR joinmarket.cfg CONFIG FILE
# (You can also use command line flags; see --help for this script).
jlog = get_log()
class YieldGeneratorPrivacyEnhanced(YieldGeneratorBasic):
def __init__(self, wallet_service, offerconfig):
@ -69,8 +77,9 @@ class YieldGeneratorPrivacyEnhanced(YieldGeneratorBasic):
self.minsize * (1 - float(self.size_factor)),
self.minsize * (1 + float(self.size_factor))))
if randomize_minsize < jm_single().DUST_THRESHOLD:
jlog.warn("Minsize was randomized to below dust; resetting to dust "
"threshold: " + amount_to_str(jm_single().DUST_THRESHOLD))
jlog.warning("Minsize was randomized to below dust; resetting to"
" dust threshold: " +
amount_to_str(jm_single().DUST_THRESHOLD))
randomize_minsize = jm_single().DUST_THRESHOLD
possible_maxsize = mix_balance[max_mix] - max(jm_single().DUST_THRESHOLD, randomize_txfee)
randomize_maxsize = int(random.uniform(possible_maxsize * (1 - float(self.size_factor)),
@ -107,6 +116,14 @@ class YieldGeneratorPrivacyEnhanced(YieldGeneratorBasic):
return [order]
if __name__ == "__main__":
ygmain(YieldGeneratorPrivacyEnhanced, nickserv_password='')
@wrap_main
async def _main():
await ygmain(YieldGeneratorPrivacyEnhanced, nickserv_password='')
jmprint('done', "success")
if __name__ == "__main__":
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

19
scripts/yield-generator-basic.py

@ -1,11 +1,26 @@
#!/usr/bin/env python3
import asyncio
import jmclient # noqa: F401 install asyncioreactor
from twisted.internet import reactor
from jmclient.scripts_support import wrap_main, finalize_main_task
from jmbase import jmprint
from jmclient import YieldGeneratorBasic, ygmain
# YIELD GENERATOR SETTINGS ARE NOW IN YOUR joinmarket.cfg CONFIG FILE
# (You can also use command line flags; see --help for this script).
@wrap_main
async def _main():
await ygmain(YieldGeneratorBasic, nickserv_password='')
jmprint("done", "success")
if __name__ == "__main__":
ygmain(YieldGeneratorBasic, nickserv_password='')
jmprint('done', "success")
asyncio_loop = asyncio.get_event_loop()
main_task = asyncio_loop.create_task(_main())
reactor.run()
finalize_main_task(main_task)

3
src/jmbase/__init__.py

@ -9,7 +9,8 @@ from .support import (get_log, chunks, debug_silence, jmprint,
JM_WALLET_NAME_PREFIX, JM_APP_NAME,
IndentedHelpFormatterWithNL, wrapped_urlparse,
bdict_sdict_convert, random_insert, dict_factory,
cli_prompt_user_value, cli_prompt_user_yesno)
cli_prompt_user_value, cli_prompt_user_yesno,
async_hexbin, twisted_sys_exit, is_running_from_pytest)
from .proof_of_work import get_pow, verify_pow
from .twisted_utils import (stop_reactor, is_hs_uri, get_tor_agent,
get_nontor_agent, JMHiddenService,

192
src/jmbase/commands.py

@ -78,6 +78,101 @@ class JMShutdown(JMCommand):
"""
arguments = []
"""Messages used by DKG parties"""
class JMDKGInit(JMCommand):
arguments = [
(b'hostpubkeyhash', Unicode()),
(b'session_id', Unicode()),
(b'sig', Unicode()),
]
class JMDKGPMsg1(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'hostpubkeyhash', Unicode()),
(b'session_id', Unicode()),
(b'sig', Unicode()),
(b'pmsg1', Unicode()),
]
class JMDKGPMsg2(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
(b'pmsg2', Unicode()),
]
class JMDKGCMsg1(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
(b'cmsg1', Unicode()),
]
class JMDKGCMsg2(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
(b'cmsg2', Unicode()),
(b'ext_recovery', Unicode()),
]
class JMDKGFinalized(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
]
"""Messages used by FROST parties"""
class JMFROSTReq(JMCommand):
arguments = [
(b'hostpubkeyhash', Unicode()),
(b'sig', Unicode()),
(b'session_id', Unicode()),
]
class JMFROSTAck(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'hostpubkeyhash', Unicode()),
(b'sig', Unicode()),
(b'session_id', Unicode()),
]
class JMFROSTInit(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
]
class JMFROSTRound1(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
(b'pub_nonce', Unicode()),
]
class JMFROSTAgg1(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
(b'nonce_agg', Unicode()),
(b'dkg_session_id', Unicode()),
(b'ids', Unicode()),
(b'msg', Unicode()),
]
class JMFROSTRound2(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
(b'partial_sig', Unicode()),
]
"""TAKER specific commands
"""
@ -193,6 +288,103 @@ class JMRequestMsgSigVerify(JMCommand):
(b'max_encoded', Integer()),
(b'hostid', Unicode())]
"""Messages used by DKG parties"""
class JMDKGInitSeen(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'hostpubkeyhash', Unicode()),
(b'session_id', Unicode()),
(b'sig', Unicode()),
]
class JMDKGPMsg1Seen(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'hostpubkeyhash', Unicode()),
(b'session_id', Unicode()),
(b'sig', Unicode()),
(b'pmsg1', Unicode()),
]
class JMDKGPMsg2Seen(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
(b'pmsg2', Unicode()),
]
class JMDKGFinalizedSeen(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
]
class JMDKGCMsg1Seen(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
(b'cmsg1', Unicode()),
]
class JMDKGCMsg2Seen(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
(b'cmsg2', Unicode()),
(b'ext_recovery', Unicode()),
]
"""Messages used by FROST parties"""
class JMFROSTReqSeen(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'hostpubkeyhash', Unicode()),
(b'sig', Unicode()),
(b'session_id', Unicode()),
]
class JMFROSTAckSeen(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'hostpubkeyhash', Unicode()),
(b'sig', Unicode()),
(b'session_id', Unicode()),
]
class JMFROSTInitSeen(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
]
class JMFROSTRound1Seen(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
(b'pub_nonce', Unicode()),
]
class JMFROSTAgg1Seen(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
(b'nonce_agg', Unicode()),
(b'dkg_session_id', Unicode()),
(b'ids', Unicode()),
(b'msg', Unicode()),
]
class JMFROSTRound2Seen(JMCommand):
arguments = [
(b'nick', Unicode()),
(b'session_id', Unicode()),
(b'partial_sig', Unicode()),
]
""" TAKER-specific commands
"""

31
src/jmbase/support.py

@ -11,6 +11,7 @@ from sqlite3 import Cursor, Row
from typing import Callable, List, Optional
import urllib.parse as urlparse
# JoinMarket version
JM_CORE_VERSION = '0.9.12dev'
@ -23,6 +24,13 @@ EXIT_SUCCESS = 0
EXIT_FAILURE = 1
EXIT_ARGERROR = 2
def twisted_sys_exit(status):
from .twisted_utils import stop_reactor
stop_reactor()
sys.exit(status)
# optparse munges description paragraphs. We sometimes
# don't want that.
class IndentedHelpFormatterWithNL(IndentedHelpFormatter):
@ -212,6 +220,9 @@ def get_password(msg): #pragma: no cover
password = password.encode('utf-8')
return password
def is_running_from_pytest():
return bool(environ.get("PYTEST_CURRENT_TEST"))
def lookup_appdata_folder(appname):
""" Given an appname as a string,
return the correct directory for storing
@ -224,7 +235,7 @@ def lookup_appdata_folder(appname):
appname) + '/'
else:
jmprint("Could not find home folder")
sys.exit(EXIT_FAILURE)
twisted_sys_exit(EXIT_FAILURE)
elif 'win32' in sys.platform or 'win64' in sys.platform:
data_folder = path.join(environ['APPDATA'], appname) + '\\'
@ -237,7 +248,7 @@ def get_jm_version_str():
def print_jm_version(option, opt_str, value, parser):
print(get_jm_version_str())
sys.exit(EXIT_SUCCESS)
twisted_sys_exit(EXIT_SUCCESS)
# helper functions for conversions of format between over-the-wire JM
# and internal. See details in hexbin() docstring.
@ -303,6 +314,22 @@ def hexbin(func):
return func_wrapper
def async_hexbin(func):
@wraps(func)
async def func_wrapper(inst, *args, **kwargs):
newargs = []
for arg in args:
if isinstance(arg, (list, tuple)):
newargs.append(listchanger(arg))
elif isinstance(arg, dict):
newargs.append(dictchanger(arg))
else:
newargs.append(_convert(arg))
return await func(inst, *newargs, **kwargs)
return func_wrapper
def wrapped_urlparse(url):
""" This wrapper is unfortunately necessary as there appears
to be a bug in the urlparse handling of *.onion strings:

243
src/jmbitcoin/scripteval.py

@ -0,0 +1,243 @@
# -*- coding: utf-8 -*-
import hashlib
from typing import List, Optional, Tuple, Union, Set, Type
import bitcointx
from bitcointx.core.key import XOnlyPubKey
from bitcointx.core.script import SIGVERSION_TAPROOT, SignatureHashSchnorr
from bitcointx.core import CTxOut
from bitcointx.core.scripteval import (
CScript, ScriptVerifyFlag_Type, CScriptWitness, VerifyScriptError,
STANDARD_SCRIPT_VERIFY_FLAGS, UNHANDLED_SCRIPT_VERIFY_FLAGS, EvalScript,
SCRIPT_VERIFY_CLEANSTACK, script_verify_flags_to_string, _CastToBool,
ensure_isinstance, SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM,
MAX_SCRIPT_ELEMENT_SIZE, OP_CHECKSIG, OP_EQUALVERIFY, OP_HASH160, OP_DUP)
from bitcointx.core.scripteval import (
SCRIPT_VERIFY_WITNESS, SCRIPT_VERIFY_P2SH, SIGVERSION_WITNESS_V0)
def VerifyScriptWithTaproot(
scriptSig: CScript, scriptPubKey: CScript,
txTo: 'bitcointx.core.CTransaction', inIdx: int,
flags: Optional[Union[Tuple[ScriptVerifyFlag_Type, ...],
Set[ScriptVerifyFlag_Type]]] = None,
amount: int = 0, witness: Optional[CScriptWitness] = None,
*,
spent_outputs: Optional[List[CTxOut]] = None
) -> None:
"""Verify a scriptSig satisfies a scriptPubKey
scriptSig - Signature
scriptPubKey - PubKey
txTo - Spending transaction
inIdx - Index of the transaction input containing scriptSig
Raises a ValidationError subclass if the validation fails.
"""
ensure_isinstance(scriptSig, CScript, 'scriptSig')
if not type(scriptSig) == type(scriptPubKey): # noqa: exact class check
raise TypeError(
"scriptSig and scriptPubKey must be of the same script class")
script_class = scriptSig.__class__
if flags is None:
flags = STANDARD_SCRIPT_VERIFY_FLAGS - UNHANDLED_SCRIPT_VERIFY_FLAGS
else:
flags = set(flags) # might be passed as tuple
if flags & UNHANDLED_SCRIPT_VERIFY_FLAGS:
raise VerifyScriptError(
"some of the flags cannot be handled by current code: {}"
.format(script_verify_flags_to_string(flags & UNHANDLED_SCRIPT_VERIFY_FLAGS)))
stack: List[bytes] = []
EvalScript(stack, scriptSig, txTo, inIdx, flags=flags)
if SCRIPT_VERIFY_P2SH in flags:
stackCopy = list(stack)
EvalScript(stack, scriptPubKey, txTo, inIdx, flags=flags)
if len(stack) == 0:
raise VerifyScriptError("scriptPubKey left an empty stack")
if not _CastToBool(stack[-1]):
raise VerifyScriptError("scriptPubKey returned false")
hadWitness = False
if witness is None:
witness = CScriptWitness([])
if SCRIPT_VERIFY_WITNESS in flags and scriptPubKey.is_witness_scriptpubkey():
hadWitness = True
if scriptSig:
raise VerifyScriptError("scriptSig is not empty")
VerifyWitnessProgramWithTaproot(
witness,
scriptPubKey.witness_version(),
scriptPubKey.witness_program(),
txTo, inIdx, flags=flags, amount=amount,
script_class=script_class,
spent_outputs=spent_outputs)
# Bypass the cleanstack check at the end. The actual stack is obviously not clean
# for witness programs.
stack = stack[:1]
# Additional validation for spend-to-script-hash transactions
if SCRIPT_VERIFY_P2SH in flags and scriptPubKey.is_p2sh():
if not scriptSig.is_push_only():
raise VerifyScriptError("P2SH scriptSig not is_push_only()")
# restore stack
stack = stackCopy
# stack cannot be empty here, because if it was the
# P2SH HASH <> EQUAL scriptPubKey would be evaluated with
# an empty stack and the EvalScript above would return false.
assert len(stack)
pubKey2 = script_class(stack.pop())
EvalScript(stack, pubKey2, txTo, inIdx, flags=flags)
if not len(stack):
raise VerifyScriptError("P2SH inner scriptPubKey left an empty stack")
if not _CastToBool(stack[-1]):
raise VerifyScriptError("P2SH inner scriptPubKey returned false")
# P2SH witness program
if SCRIPT_VERIFY_WITNESS in flags and pubKey2.is_witness_scriptpubkey():
hadWitness = True
if scriptSig != script_class([pubKey2]):
raise VerifyScriptError("scriptSig is not exactly a single push of the redeemScript")
VerifyWitnessProgramWithTaproot(
witness,
pubKey2.witness_version(),
pubKey2.witness_program(),
txTo, inIdx, flags=flags, amount=amount,
script_class=script_class,
spent_outputs=spent_outputs)
# Bypass the cleanstack check at the end. The actual stack is obviously not clean
# for witness programs.
stack = stack[:1]
if SCRIPT_VERIFY_CLEANSTACK in flags:
if SCRIPT_VERIFY_P2SH not in flags:
raise ValueError(
'SCRIPT_VERIFY_CLEANSTACK requires SCRIPT_VERIFY_P2SH')
if len(stack) == 0:
raise VerifyScriptError("scriptPubKey left an empty stack")
elif len(stack) != 1:
raise VerifyScriptError("scriptPubKey left extra items on stack")
if SCRIPT_VERIFY_WITNESS in flags:
# We can't check for correct unexpected witness data if P2SH was off, so require
# that WITNESS implies P2SH. Otherwise, going from WITNESS->P2SH+WITNESS would be
# possible, which is not a softfork.
if SCRIPT_VERIFY_P2SH not in flags:
raise ValueError(
"SCRIPT_VERIFY_WITNESS requires SCRIPT_VERIFY_P2SH")
if not hadWitness and witness:
raise VerifyScriptError("Unexpected witness")
def VerifyWitnessProgramWithTaproot(
witness: CScriptWitness,
witversion: int, program: bytes,
txTo: 'bitcointx.core.CTransaction',
inIdx: int,
flags: Set[ScriptVerifyFlag_Type] = set(),
amount: int = 0,
script_class: Type[CScript] = CScript,
*,
spent_outputs: Optional[List[CTxOut]] = None
) -> None:
if script_class is None:
raise ValueError("script class must be specified")
sigversion = None
if witversion == 0:
sigversion = SIGVERSION_WITNESS_V0
stack = list(witness.stack)
if len(program) == 32:
# Version 0 segregated witness program: SHA256(CScript) inside the program,
# CScript + inputs in witness
if len(stack) == 0:
raise VerifyScriptError("witness is empty")
scriptPubKey = script_class(stack.pop())
hashScriptPubKey = hashlib.sha256(scriptPubKey).digest()
if hashScriptPubKey != program:
raise VerifyScriptError("witness program mismatch")
elif len(program) == 20:
# Special case for pay-to-pubkeyhash; signature + pubkey in witness
if len(stack) != 2:
raise VerifyScriptError("witness program mismatch") # 2 items in witness
scriptPubKey = script_class([OP_DUP, OP_HASH160, program,
OP_EQUALVERIFY, OP_CHECKSIG])
else:
raise VerifyScriptError("wrong length for witness program")
elif witversion == 1:
sigversion = SIGVERSION_TAPROOT
stack = list(witness.stack)
if len(program) == 32:
if len(stack) == 0:
raise VerifyScriptError("witness is empty")
if len(stack) != 1:
raise VerifyScriptError("only key path spend is supported")
assert spent_outputs
sig = stack[0]
pubkey = XOnlyPubKey(program)
sighash = SignatureHashSchnorr(txTo, inIdx, spent_outputs)
if pubkey.verify_schnorr(sighash, sig):
return
else:
raise VerifyScriptError("schnorr signature verify failed")
else:
raise VerifyScriptError("wrong length for witness program")
elif SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM in flags:
raise VerifyScriptError("upgradeable witness program is not accepted")
else:
# Higher version witness scripts return true for future softfork compatibility
return
assert sigversion is not None
for i, elt in enumerate(stack):
if isinstance(elt, int):
elt_len = len(script_class([elt]))
else:
elt_len = len(elt)
# Disallow stack item size > MAX_SCRIPT_ELEMENT_SIZE in witness stack
if elt_len > MAX_SCRIPT_ELEMENT_SIZE:
raise VerifyScriptError(
"maximum push size exceeded by an item at position {} "
"on witness stack".format(i))
EvalScript(stack, scriptPubKey, txTo, inIdx, flags=flags, amount=amount, sigversion=sigversion)
# Scripts inside witness implicitly require cleanstack behaviour
if len(stack) == 0:
raise VerifyScriptError("scriptPubKey left an empty stack")
elif len(stack) != 1:
raise VerifyScriptError("scriptPubKey left extra items on stack")
if not _CastToBool(stack[-1]):
raise VerifyScriptError("scriptPubKey returned false")
return

10
src/jmbitcoin/secp256k1_deterministic.py

@ -12,8 +12,10 @@ TESTNET_PRIVATE = b'\x04\x35\x83\x94'
TESTNET_PUBLIC = b'\x04\x35\x87\xCF'
SIGNET_PRIVATE = b'\x04\x35\x83\x94'
SIGNET_PUBLIC = b'\x04\x35\x87\xCF'
PRIVATE = [MAINNET_PRIVATE, TESTNET_PRIVATE, SIGNET_PRIVATE]
PUBLIC = [MAINNET_PUBLIC, TESTNET_PUBLIC, SIGNET_PUBLIC]
TESTNET4_PRIVATE = b'\x04\x35\x83\x94'
TESTNET4_PUBLIC = b'\x04\x35\x87\xCF'
PRIVATE = [MAINNET_PRIVATE, TESTNET_PRIVATE, SIGNET_PRIVATE, TESTNET4_PRIVATE]
PUBLIC = [MAINNET_PUBLIC, TESTNET_PUBLIC, SIGNET_PUBLIC, TESTNET4_PUBLIC]
privtopub = privkey_to_pubkey
@ -97,6 +99,10 @@ def bip32_master_key(seed, vbytes=MAINNET_PRIVATE):
return bip32_serialize((vbytes, 0, b'\x00' * 4, 0, I[32:], I[:32] + b'\x01'
))
def hostseckey_from_entropy(seed):
return hmac.new("Bitcoin seed".encode("utf-8"),
seed, hashlib.sha256).digest() + b'\x01'
def bip32_extract_key(data):
return bip32_deserialize(data)[-1]

5
src/jmbitcoin/secp256k1_main.py

@ -22,8 +22,9 @@ secp_obj.lib.secp256k1_ec_pubkey_tweak_mul.argtypes = [ctypes.c_void_p, ctypes.c
N = 115792089237316195423570985008687907852837564279074904382605163141518161494337
BTC_P2PK_VBYTE = {"mainnet": b'\x00', "testnet": b'\x6f', "signet": b'\x6f',
"regtest": 100}
BTC_P2SH_VBYTE = {"mainnet": b'\x05', "testnet": b'\xc4', "signet": b'\xc4'}
"regtest": 100, "testnet4": b'\x6f'}
BTC_P2SH_VBYTE = {"mainnet": b'\x05', "testnet": b'\xc4', "signet": b'\xc4',
"testnet4": b'\xc4'}
"""PoDLE related primitives
"""

126
src/jmbitcoin/secp256k1_transaction.py

@ -12,15 +12,17 @@ from bitcointx.core import (CMutableTransaction, CTxInWitness,
CMutableTxOut, CTxIn, CTxOut, ValidationError,
CBitcoinTransaction)
from bitcointx.core.script import *
from bitcointx.wallet import (P2WPKHCoinAddress, CCoinAddress, P2PKHCoinAddress,
CCoinAddressError)
from bitcointx.core.scripteval import (VerifyScript, SCRIPT_VERIFY_WITNESS,
from bitcointx.core.script import SignatureHashSchnorr
from bitcointx.wallet import (P2WPKHCoinAddress, P2TRCoinAddress, CCoinAddress,
P2PKHCoinAddress, CCoinAddressError, CCoinKey)
from bitcointx.core.scripteval import (SCRIPT_VERIFY_WITNESS,
SCRIPT_VERIFY_P2SH,
SCRIPT_VERIFY_STRICTENC,
SIGVERSION_WITNESS_V0)
from jmbase import bintohex, utxo_to_utxostr
from jmbitcoin.secp256k1_main import *
from .scripteval import VerifyScriptWithTaproot as VerifyScript
def human_readable_transaction(tx: CTransaction, jsonified: bool = True) -> str:
@ -137,7 +139,8 @@ def estimate_tx_size(ins: List[str], outs: List[str]) -> Union[int, Tuple[int]]:
inmults = {"p2wsh": {"w": 1 + 72 + 43, "nw": 41},
"p2wpkh": {"w": 108, "nw": 41},
"p2sh-p2wpkh": {"w": 108, "nw": 64},
"p2pkh": {"w": 0, "nw": 148}}
"p2pkh": {"w": 0, "nw": 148},
"p2tr": {"w": 67, "nw": 41}}
# Notes: in outputs, there is only 1 'scripthash'
# type for either segwit/nonsegwit (hence "p2sh-p2wpkh"
@ -215,6 +218,12 @@ def pubkey_to_p2sh_p2wpkh_script(pub: bytes) -> CScript:
raise Exception("Invalid pubkey")
return pubkey_to_p2wpkh_script(pub).to_p2sh_scriptPubKey()
def pubkey_to_p2tr_script(pub: bytes) -> CScript:
return P2TRCoinAddress.from_pubkey(pub).to_scriptPubKey()
def output_pubkey_to_p2tr_script(pub: bytes) -> CScript:
return P2TRCoinAddress.from_output_pubkey(pub).to_scriptPubKey()
def redeem_script_to_p2wsh_script(redeem_script: Union[bytes, CScript]) -> CScript:
""" Given redeem script of type CScript (or bytes)
returns the corresponding segwit v0 scriptPubKey as
@ -246,12 +255,16 @@ def mk_burn_script(data: bytes) -> CScript:
raise TypeError("data must be in bytes")
return CScript([OP_RETURN, data])
def sign(tx: CMutableTransaction,
i: int,
priv: bytes,
hashcode: SIGHASH_Type = SIGHASH_ALL,
amount: Optional[int] = None,
native: bool = False) -> Tuple[Optional[bytes], str]:
def sign(
tx: CMutableTransaction,
i: int,
priv: bytes,
hashcode: SIGHASH_Type = SIGHASH_ALL,
amount: Optional[int] = None,
native: bool = False,
*,
spent_outputs: Optional[List[CTxOut]] = None
) -> Tuple[Optional[bytes], str]:
"""
Given a transaction tx of type CMutableTransaction, an input index i,
and a raw privkey in bytes, updates the CMutableTransaction to contain
@ -285,20 +298,44 @@ def sign(tx: CMutableTransaction,
tx.vin[i].scriptSig = CScript([sig, pub])
# Verify the signature worked.
try:
VerifyScript(tx.vin[i].scriptSig,
input_scriptPubKey, tx, i, flags=flags)
VerifyScript(
tx.vin[i].scriptSig, input_scriptPubKey, tx, i, flags=flags,
spent_outputs=spent_outputs)
except Exception as e:
return return_err(e)
return sig, "signing succeeded"
else:
# segwit case; we currently support p2wpkh native or under p2sh.
# segwit case; we currently support p2tr, p2wpkh native or under p2sh.
# https://github.com/Simplexum/python-bitcointx/blob/648ad8f45ff853bf9923c6498bfa0648b3d7bcbd/bitcointx/core/scripteval.py#L1250-L1252
flags.add(SCRIPT_VERIFY_P2SH)
flags.add(SCRIPT_VERIFY_WITNESS)
if native and native != "p2wpkh":
if native and native == "p2tr":
assert spent_outputs
sighash = SignatureHashSchnorr(tx, i, spent_outputs)
try:
coin_key = CCoinKey.from_secret_bytes(priv[:32])
sig = coin_key.sign_schnorr_tweaked(sighash)
except Exception as e:
return return_err(e)
witness = [sig]
ctxwitness = CTxInWitness(CScriptWitness(witness))
tx.wit.vtxinwit[i] = ctxwitness
try:
input_scriptPubKey = pubkey_to_p2tr_script(pub)
VerifyScript(
tx.vin[i].scriptSig, input_scriptPubKey, tx, i,
flags=flags, amount=amount,
witness=tx.wit.vtxinwit[i].scriptWitness,
spent_outputs=spent_outputs)
except ValidationError as e:
return return_err(e)
return sig, "signing succeeded"
elif native and native != "p2wpkh":
scriptCode = native
input_scriptPubKey = redeem_script_to_p2wsh_script(native)
else:
@ -328,13 +365,52 @@ def sign(tx: CMutableTransaction,
tx.wit.vtxinwit[i] = ctxwitness
# Verify the signature worked.
try:
VerifyScript(tx.vin[i].scriptSig, input_scriptPubKey, tx, i,
flags=flags, amount=amount, witness=tx.wit.vtxinwit[i].scriptWitness)
VerifyScript(
tx.vin[i].scriptSig, input_scriptPubKey, tx, i, flags=flags,
amount=amount, witness=tx.wit.vtxinwit[i].scriptWitness,
spent_outputs=spent_outputs)
except ValidationError as e:
return return_err(e)
return sig, "signing succeeded"
def add_frost_sig(
tx: CMutableTransaction,
i: int,
pub: bytes,
sig: bytes,
amount: Optional[int] = None,
*,
spent_outputs: Optional[List[CTxOut]] = None
) -> Tuple[Optional[bytes], str]:
# script verification flags
flags = set([SCRIPT_VERIFY_STRICTENC])
def return_err(e):
return None, "Error in signing: " + repr(e)
assert isinstance(tx, CMutableTransaction)
flags.add(SCRIPT_VERIFY_P2SH)
flags.add(SCRIPT_VERIFY_WITNESS)
assert spent_outputs
witness = [sig]
ctxwitness = CTxInWitness(CScriptWitness(witness))
tx.wit.vtxinwit[i] = ctxwitness
try:
input_scriptPubKey = pubkey_to_p2tr_script(pub)
VerifyScript(
tx.vin[i].scriptSig, input_scriptPubKey, tx, i,
flags=flags, amount=amount,
witness=tx.wit.vtxinwit[i].scriptWitness,
spent_outputs=spent_outputs)
except ValidationError as e:
return return_err(e)
return sig, "signing succeeded"
def mktx(ins: List[Tuple[bytes, int]],
outs: List[dict],
version: int = 1,
@ -387,19 +463,23 @@ def make_shuffled_tx(ins: List[Tuple[bytes, int]],
return mktx(ins, outs, version=version, locktime=locktime)
def verify_tx_input(tx: CTransaction,
i: int,
scriptSig: CScript,
scriptPubKey: CScript,
amount: Optional[int] = None,
witness: Optional[CScriptWitness] = None) -> bool:
i: int,
scriptSig: CScript,
scriptPubKey: CScript,
amount: Optional[int] = None,
witness: Optional[CScriptWitness] = None,
*,
spent_outputs: Optional[List[CTxOut]] = None
) -> bool:
flags = set([SCRIPT_VERIFY_STRICTENC])
if witness:
# https://github.com/Simplexum/python-bitcointx/blob/648ad8f45ff853bf9923c6498bfa0648b3d7bcbd/bitcointx/core/scripteval.py#L1250-L1252
flags.add(SCRIPT_VERIFY_P2SH)
flags.add(SCRIPT_VERIFY_WITNESS)
try:
VerifyScript(scriptSig, scriptPubKey, tx, i,
flags=flags, amount=amount, witness=witness)
VerifyScript(
scriptSig, scriptPubKey, tx, i, flags=flags, amount=amount,
witness=witness, spent_outputs=spent_outputs)
except ValidationError as e:
return False
return True

26
src/jmclient/__init__.py

@ -1,5 +1,14 @@
# -*- coding: utf-8 -*-
import asyncio
import logging
import sys
if 'twisted.internet.reactor' not in sys.modules:
from twisted.internet import asyncioreactor
asyncio_loop = asyncio.get_event_loop()
asyncio_loop.set_debug(False)
asyncioreactor.install(asyncio_loop)
from .support import (calc_cj_fee, choose_sweep_orders, choose_orders,
cheapest_order_choose, weighted_order_choose,
@ -17,9 +26,12 @@ from .wallet import (Mnemonic, estimate_tx_fee, WalletError, BaseWallet, ImportW
SegwitWallet, SegwitLegacyWallet, FidelityBondMixin,
FidelityBondWatchonlyWallet, SegwitWalletFidelityBonds,
UTXOManager, WALLET_IMPLEMENTATIONS, compute_tx_locktime,
UnknownAddressForLabel)
UnknownAddressForLabel, TaprootWallet, FrostWallet,
TaprootWalletFidelityBonds, DKGManager,
TaprootFidelityBondWatchonlyWallet)
from .storage import (Argon2Hash, Storage, StorageError, RetryableStorageError,
StoragePasswordError, VolatileStorage)
StoragePasswordError, VolatileStorage,
DKGStorage, DKGRecoveryStorage)
from .cryptoengine import (BTCEngine, BTC_P2PKH, BTC_P2SH_P2WPKH, BTC_P2WPKH, EngineError,
TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, detect_script_type,
is_extended_public_key)
@ -27,8 +39,8 @@ from .configure import (load_test_config, process_shutdown,
load_program_config, jm_single, get_network, update_persist_config,
validate_address, is_burn_destination, get_mchannels,
get_blockchain_interface_instance, set_config, is_segwit_mode,
is_native_segwit_mode, JMPluginService, get_interest_rate,
get_bondless_makers_allowance, check_and_start_tor)
is_taproot_mode, is_native_segwit_mode, JMPluginService, get_interest_rate,
get_bondless_makers_allowance, check_and_start_tor, is_frost_mode)
from .blockchaininterface import (BlockchainInterface,
RegtestBitcoinCoreInterface, BitcoinCoreInterface)
from .snicker_receiver import SNICKERError, SNICKERReceiver
@ -63,8 +75,8 @@ from .wallet_utils import (
wallet_change_passphrase, wallet_signmessage)
from .wallet_service import WalletService
from .maker import Maker
from .yieldgenerator import YieldGenerator, YieldGeneratorBasic, ygmain, \
YieldGeneratorService
from .yieldgenerator import (YieldGenerator, YieldGeneratorBasic, ygmain,
YieldGeneratorService)
from .snicker_receiver import SNICKERError, SNICKERReceiver, SNICKERReceiverService
from .payjoin import (parse_payjoin_setup, send_payjoin,
JMBIP78ReceiverManager)
@ -72,6 +84,8 @@ from .websocketserver import JmwalletdWebSocketServerFactory, \
JmwalletdWebSocketServerProtocol
from .wallet_rpc import JMWalletDaemon
from .bond_calc import get_bond_values
from .frost_clients import FROSTClient
from .frost_ipc import FrostIPCServer, FrostIPCClient
# Set default logging handler to avoid "No handler found" warnings.
try:

3
src/jmclient/auth.py

@ -76,7 +76,8 @@ class JMTokenAuthority:
def _issue(self, token_type: str) -> str:
return jwt.encode(
{
"exp": datetime.datetime.utcnow() + self.SESSION_VALIDITY[token_type],
"exp": datetime.datetime.now(datetime.UTC) +
self.SESSION_VALIDITY[token_type],
"scope": self.scope,
},
self.signature_key[token_type],

97
src/jmclient/blockchaininterface.py

@ -1,7 +1,6 @@
import ast
import binascii
import random
import sys
import time
from abc import ABC, abstractmethod
from decimal import Decimal
@ -11,7 +10,7 @@ from twisted.internet import reactor, task
import jmbitcoin as btc
from jmbase import bintohex, hextobin, stop_reactor
from jmbase.support import get_log, jmprint, EXIT_FAILURE
from jmbase.support import get_log, jmprint, EXIT_FAILURE, twisted_sys_exit
from jmclient.configure import jm_single
from jmclient.jsonrpc import JsonRpc, JsonRpcConnectionError, JsonRpcError
@ -289,8 +288,8 @@ class BlockchainInterface(ABC):
msg = msg + " (randomized for privacy)"
fallback_fee_randomized = random.uniform(
fallback_fee, fallback_fee * float(1 + tx_fees_factor))
log.warn(msg + ": " +
btc.fee_per_kb_to_str(fallback_fee_randomized) + ".")
log.warning(msg + ": " +
btc.fee_per_kb_to_str(fallback_fee_randomized) + ".")
return int(fallback_fee_randomized)
feerate, blocks = retval
@ -342,7 +341,7 @@ class BitcoinCoreInterface(BlockchainInterface):
actualNet = blockchainInfo['chain']
netmap = {'main': 'mainnet', 'test': 'testnet', 'regtest': 'regtest',
'signet': 'signet'}
'signet': 'signet', 'testnet4': 'testnet4'}
if netmap[actualNet] != network and \
(not (actualNet == "regtest" and network == "testnet")):
#special case of regtest and testnet having the same addr format
@ -443,7 +442,7 @@ class BitcoinCoreInterface(BlockchainInterface):
log.error("Failure of RPC connection to Bitcoin Core. "
"Application cannot continue, shutting down.")
stop_reactor()
return None
twisted_sys_exit(EXIT_FAILURE)
# note that JsonRpcError is not caught here; for some calls, we
# have specific behaviour requirements depending on these errors,
# so this is handled elsewhere in BitcoinCoreInterface.
@ -478,7 +477,7 @@ class BitcoinCoreInterface(BlockchainInterface):
if row['success'] == False:
num_failed += 1
# don't try/catch, assume failure always has error message
log.warn(row['error']['message'])
log.warning(row['error']['message'])
if num_failed > 0:
fatal_msg = ("Fatal sync error: import of {} address(es) failed for "
"some reason. To prevent coin or privacy loss, "
@ -490,7 +489,7 @@ class BitcoinCoreInterface(BlockchainInterface):
restart_cb(fatal_msg)
else:
jmprint(fatal_msg, "important")
sys.exit(EXIT_FAILURE)
twisted_sys_exit(EXIT_FAILURE)
def import_addresses_if_needed(self, addresses: Set[str], wallet_name: str) -> bool:
if wallet_name in self._rpc('listlabels', []):
@ -503,6 +502,49 @@ class BitcoinCoreInterface(BlockchainInterface):
self.import_addresses(addresses - imported_addresses, wallet_name)
return import_needed
def import_descriptors(self, desc_list: Iterable[str], wallet_name: str,
restart_cb: Callable[[str], None] = None) -> None:
requests = []
for desc in desc_list:
requests.append({
"desc": desc,
"timestamp": "now",
"label": wallet_name,
"internal": False
})
result = self._rpc('importdescriptors', [requests])
num_failed = 0
for row in result:
if row['success'] == False:
num_failed += 1
# don't try/catch, assume failure always has error message
log.warning(row['error']['message'])
if num_failed > 0:
fatal_msg = ("Fatal sync error: import of {} address(es) failed for "
"some reason. To prevent coin or privacy loss, "
"Joinmarket will not load a wallet in this conflicted "
"state. Try using a new Bitcoin Core wallet to sync this "
"Joinmarket wallet, or use a new Joinmarket wallet."
"".format(num_failed))
if restart_cb:
restart_cb(fatal_msg)
else:
jmprint(fatal_msg, "important")
twisted_sys_exit(EXIT_FAILURE)
def import_descriptors_if_needed(self, descriptors: Set[str], wallet_name: str) -> bool:
if wallet_name in self._rpc('listlabels', []):
list_desc = self._rpc('listdescriptors', []).get('descriptors', [])
imported_descriptors = set([x['desc'] for x in list_desc])
else:
imported_descriptors = set()
import_needed = not descriptors.issubset(imported_descriptors)
if import_needed:
self.import_descriptors(descriptors - imported_descriptors, wallet_name)
return import_needed
def get_deser_from_gettransaction(self, rpcretval: dict) -> Optional[btc.CMutableTransaction]:
if not "hex" in rpcretval:
log.info("Malformed gettransaction output")
@ -519,11 +561,11 @@ class BitcoinCoreInterface(BlockchainInterface):
res = self._rpc("gettransaction", [htxid, True])
except JsonRpcError as e:
#This should never happen (gettransaction is a wallet rpc).
log.warn("Failed gettransaction call; JsonRpcError: " + repr(e))
log.warning("Failed gettransaction call; JsonRpcError: " + repr(e))
return None
except Exception as e:
log.warn("Failed gettransaction call; unexpected error:")
log.warn(str(e))
log.warning("Failed gettransaction call; unexpected error:")
log.warning(str(e))
return None
if res is None:
# happens in case of rpc connection failure:
@ -533,6 +575,23 @@ class BitcoinCoreInterface(BlockchainInterface):
return None
return res
def getrawtransaction(self, txid: bytes) -> Optional[dict]:
htxid = bintohex(txid)
try:
res = self._rpc("getrawtransaction", [htxid])
except JsonRpcError as e:
log.warning("Failed getrawtransaction call; JsonRpcError: " +
repr(e))
return None
except Exception as e:
log.warning("Failed getrawtransaction call; unexpected error:")
log.warning(str(e))
return None
if res is None:
# happens in case of rpc connection failure:
return None
return res
def pushtx(self, txbin: bytes) -> bool:
""" Given a binary serialized valid bitcoin transaction,
broadcasts it to the network.
@ -558,13 +617,13 @@ class BitcoinCoreInterface(BlockchainInterface):
for txo in txouts:
txo_hex = bintohex(txo[0])
if len(txo_hex) != 64:
log.warn("Invalid utxo format, ignoring: {}".format(txo))
log.warning("Invalid utxo format, ignoring: {}".format(txo))
result.append(None)
continue
try:
txo_idx = int(txo[1])
except ValueError:
log.warn("Invalid utxo format, ignoring: {}".format(txo))
log.warning("Invalid utxo format, ignoring: {}".format(txo))
result.append(None)
continue
ret = self._rpc('gettxout', [txo_hex, txo_idx, include_mempool])
@ -616,7 +675,7 @@ class BitcoinCoreInterface(BlockchainInterface):
if estimate and estimate > 0:
return (btc.btc_to_sat(estimate), rpc_result.get('blocks'))
# cannot get a valid estimate after `tries` tries:
log.warn("Could not source a fee estimate from Core")
log.warning("Could not source a fee estimate from Core")
return None
def get_current_block_height(self) -> int:
@ -720,6 +779,8 @@ class RegtestBitcoinCoreMixin():
if self._rpc('setgenerate', [True, reqd_blocks]):
raise Exception("Something went wrong")
"""
if not self.destn_addr:
self.destn_addr = self._rpc("getnewaddress", [])
# now we do a custom create transaction and push to the receiver
txid = self._rpc('sendtoaddress', [receiving_addr, amt])
if not txid:
@ -736,6 +797,7 @@ class BitcoinCoreNoHistoryInterface(BitcoinCoreInterface, RegtestBitcoinCoreMixi
self.import_addresses_call_count = 0
self.wallet_name = None
self.scan_result = None
self.destn_addr = None
def import_addresses_if_needed(self, addresses: Set[str], wallet_name: str) -> bool:
self.import_addresses_call_count += 1
@ -793,7 +855,8 @@ class BitcoinCoreNoHistoryInterface(BitcoinCoreInterface, RegtestBitcoinCoreMixi
wallet.disable_new_scripts = True
def tick_forward_chain(self, n: int) -> None:
self.destn_addr = self._rpc("getnewaddress", [])
if not self.destn_addr:
self.destn_addr = self._rpc("getnewaddress", [])
super().tick_forward_chain(n)
@ -810,7 +873,7 @@ class RegtestBitcoinCoreInterface(BitcoinCoreInterface, RegtestBitcoinCoreMixin)
self.absurd_fees = False
self.simulating = False
self.shutdown_signal = False
self.destn_addr = self._rpc("getnewaddress", [])
self.destn_addr = None
def estimate_fee_per_kb(self, tx_fees: int) -> int:
if not self.absurd_fees:
@ -830,6 +893,8 @@ class RegtestBitcoinCoreInterface(BitcoinCoreInterface, RegtestBitcoinCoreMixin)
self.tick_forward_chain(1)
def simulate_blocks(self) -> None:
if not self.destn_addr:
self.destn_addr = self._rpc("getnewaddress", [])
self.tickchainloop = task.LoopingCall(self.tickchain)
self.tickchainloop.start(self.tick_forward_chain_interval)
self.simulating = True

8
src/jmclient/cli_options.py

@ -1,4 +1,6 @@
#! /usr/bin/env python
import asyncio
import random
from optparse import OptionParser, OptionValueError
from configparser import NoOptionError
@ -210,8 +212,8 @@ max_cj_fee_rel = {rel_val}\n""".format(rel_val=rel_val, abs_val=abs_val))
return rel_val, abs_val
def get_max_cj_fee_values(config, parser_options,
user_callback=prompt_user_for_cj_fee):
async def get_max_cj_fee_values(config, parser_options,
user_callback=prompt_user_for_cj_fee):
""" Given a config object, retrieve the chosen maximum absolute
and relative coinjoin fees chosen by the user, or prompt
the user via the user_callback function, if not present in
@ -241,6 +243,8 @@ def get_max_cj_fee_values(config, parser_options,
if any(x is None for x in fee_values):
fee_values = user_callback(*fee_values)
if asyncio.iscoroutine(fee_values):
fee_values = await fee_values
return tuple(map(lambda j: fee_types[j](fee_values[j]),
range(len(fee_values))))

454
src/jmclient/client_protocol.py

@ -1,4 +1,8 @@
#! /usr/bin/env python
# -*- coding: utf-8 -*-
import asyncio
import base64
import time
from twisted.internet import protocol, reactor, task
from twisted.internet.error import (ConnectionLost, ConnectionAborted,
ConnectionClosed, ConnectionDone)
@ -12,14 +16,16 @@ import binascii
import json
import hashlib
import os
import sys
from jmbase import (get_log, EXIT_FAILURE, hextobin, bintohex,
utxo_to_utxostr, bdict_sdict_convert)
utxo_to_utxostr, bdict_sdict_convert, twisted_sys_exit)
from jmclient.maker import Maker
from jmclient import (jm_single, get_mchannels,
RegtestBitcoinCoreInterface,
SNICKERReceiver, process_shutdown)
SNICKERReceiver, process_shutdown, FrostWallet)
import jmbitcoin as btc
from .frost_clients import DKGClient
# module level variable representing the port
# on which the daemon is running.
# note that this var is only set if we are running
@ -94,37 +100,42 @@ class BIP78ClientProtocol(BaseClientProtocol):
self.defaultCallbacks(d)
@commands.BIP78ReceiverUp.responder
def on_BIP78_RECEIVER_UP(self, hostname):
self.manager.bip21_uri_from_onion_hostname(hostname)
async def on_BIP78_RECEIVER_UP(self, hostname):
await self.manager.bip21_uri_from_onion_hostname(hostname)
return {"accepted": True}
@commands.BIP78ReceiverOriginalPSBT.responder
def on_BIP78_RECEIVER_ORIGINAL_PSBT(self, body, params):
async def on_BIP78_RECEIVER_ORIGINAL_PSBT(self, body, params):
# TODO: we don't need binary key/vals client side, but will have to edit
# PayjoinConverter for that:
retval = self.success_callback(body.encode("utf-8"), bdict_sdict_convert(
params, output_binary=True))
if not retval[0]:
d = self.callRemote(commands.BIP78ReceiverSendError, errormsg=retval[1],
errorcode=retval[2])
cb_res = self.success_callback(
body.encode("utf-8"),
bdict_sdict_convert(params, output_binary=True))
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
if not cb_res[0]:
d = self.callRemote(commands.BIP78ReceiverSendError, errormsg=cb_res[1],
errorcode=cb_res[2])
else:
d = self.callRemote(commands.BIP78ReceiverSendProposal, psbt=retval[1])
d = self.callRemote(commands.BIP78ReceiverSendProposal, psbt=cb_res[1])
self.defaultCallbacks(d)
return {"accepted": True}
@commands.BIP78ReceiverHiddenServiceShutdown.responder
def on_BIP78_RECEIVER_HIDDEN_SERVICE_SHUTDOWN(self):
async def on_BIP78_RECEIVER_HIDDEN_SERVICE_SHUTDOWN(self):
""" This is called when the daemon has shut down the HS
because of an invalid message/error. An earlier message
will have conveyed the reason for the error.
"""
self.manager.shutdown()
await self.manager.shutdown()
return {"accepted": True}
@commands.BIP78ReceiverOnionSetupFailed.responder
def on_BIP78_RECEIVER_ONION_SETUP_FAILED(self, reason):
self.manager.info_callback(reason)
self.manager.shutdown()
async def on_BIP78_RECEIVER_ONION_SETUP_FAILED(self, reason):
cb_res = self.manager.info_callback(reason)
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
await self.manager.shutdown()
return {"accepted": True}
@commands.BIP78SenderUp.responder
@ -136,13 +147,17 @@ class BIP78ClientProtocol(BaseClientProtocol):
return {"accepted": True}
@commands.BIP78SenderReceiveProposal.responder
def on_BIP78_SENDER_RECEIVE_PROPOSAL(self, psbt):
self.success_callback(psbt, self.manager)
async def on_BIP78_SENDER_RECEIVE_PROPOSAL(self, psbt):
cb_res = self.success_callback(psbt, self.manager)
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
return {"accepted": True}
@commands.BIP78SenderReceiveError.responder
def on_BIP78_SENDER_RECEIVER_ERROR(self, errormsg, errorcode):
self.failure_callback(errormsg, errorcode, self.manager)
async def on_BIP78_SENDER_RECEIVER_ERROR(self, errormsg, errorcode):
cb_res = self.failure_callback(errormsg, errorcode, self.manager)
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
return {"accepted": True}
@commands.BIP78InfoMsg.responder
@ -258,13 +273,14 @@ class SNICKERClientProtocol(BaseClientProtocol):
try:
proposals = proposals.split("\n")
except:
jlog.warn("Error in parsing proposals from server: " + str(server))
jlog.warning("Error in parsing proposals from"
" server: " + str(server))
return {"accepted": True}
reactor.callLater(0.0, self.process_proposals, proposals)
return {"accepted": True}
def process_proposals(self, proposals):
self.client.process_proposals(proposals)
async def process_proposals(self, proposals):
await self.client.process_proposals(proposals)
if self.oneshot:
process_shutdown()
@ -386,6 +402,329 @@ class JMClientProtocol(BaseClientProtocol):
self.defaultCallbacks(d)
return {'accepted': True}
"""DKG specifics
"""
async def dkg_gen(self, session_id=None):
jlog.debug('Coordinator call dkg_gen')
client = self.factory.client
md_type_idx = None
session = None
pub = None
while True:
if md_type_idx is None:
md_type_idx = await client.dkg_gen()
if md_type_idx is None:
jlog.debug('finished dkg_gen execution')
break
if session_id is None or session_id == b'\x00'*32:
session_id, _, session = self.dkg_init(
*md_type_idx, session_id=session_id)
if session_id is None:
jlog.warning('could not get session_id from dkg_init}')
await asyncio.sleep(5)
continue
pub = await client.wait_on_dkg_output(session_id)
if not pub:
session_id = None
session = None
continue
if session.dkg_output:
md_type_idx = None
session_id = None
session = None
client.dkg_gen_list.pop(0)
continue
return pub
def dkg_init(self, mixdepth, address_type, index, session_id=None):
jlog.debug(f'Coordinator call dkg_init '
f'({mixdepth}, {address_type}, {index})')
client = self.factory.client
hostpubkeyhash, session_id, sig = client.dkg_init(
mixdepth, address_type, index, session_id=session_id)
coordinator = client.dkg_coordinators.get(session_id)
session = client.dkg_sessions.get(session_id)
if session_id and session and coordinator:
d = self.callRemote(commands.JMDKGInit,
hostpubkeyhash=hostpubkeyhash,
session_id=bintohex(session_id),
sig=sig)
self.defaultCallbacks(d)
session.dkg_init_sec = time.time()
return session_id, coordinator, session
return None, None, None
@commands.JMDKGInitSeen.responder
def on_JM_DKG_INIT_SEEN(self, nick, hostpubkeyhash, session_id, sig):
wallet = self.client.wallet_service.wallet
if not isinstance(wallet, FrostWallet) or wallet._dkg is None:
return {'accepted': True}
client = self.factory.client
session_id = hextobin(session_id)
nick, hostpubkeyhash, session_id, sig, pmsg1 = client.on_dkg_init(
nick, hostpubkeyhash, session_id, sig)
if pmsg1:
d = self.callRemote(commands.JMDKGPMsg1,
nick=nick, hostpubkeyhash=hostpubkeyhash,
session_id=session_id, sig=sig,
pmsg1=base64.b64encode(pmsg1).decode('ascii'))
self.defaultCallbacks(d)
return {'accepted': True}
@commands.JMDKGPMsg1Seen.responder
def on_JM_DKG_PMSG1_SEEN(self, nick, hostpubkeyhash,
session_id, sig, pmsg1):
wallet = self.client.wallet_service.wallet
if not isinstance(wallet, FrostWallet) or wallet._dkg is None:
return {'accepted': True}
client = self.factory.client
bin_session_id = hextobin(session_id)
pmsg1 = client.deserialize_pmsg1(base64.b64decode(pmsg1))
ready_nicks, cmsg1 = client.on_dkg_pmsg1(nick, hostpubkeyhash,
bin_session_id, sig, pmsg1)
if ready_nicks and cmsg1:
for nick in ready_nicks:
self.dkg_cmsg1(nick, session_id, cmsg1)
return {'accepted': True}
def dkg_cmsg1(self, nick, session_id, cmsg1):
d = self.callRemote(commands.JMDKGCMsg1,
nick=nick, session_id=session_id,
cmsg1=base64.b64encode(cmsg1).decode('ascii'))
self.defaultCallbacks(d)
@commands.JMDKGPMsg2Seen.responder
def on_JM_DKG_PMSG2_SEEN(self, nick, session_id, pmsg2):
wallet = self.client.wallet_service.wallet
if not isinstance(wallet, FrostWallet) or wallet._dkg is None:
return {'accepted': True}
client = self.factory.client
bin_session_id = hextobin(session_id)
pmsg2 = client.deserialize_pmsg2(base64.b64decode(pmsg2))
ready_nicks, cmsg2, ext_recovery = client.on_dkg_pmsg2(
nick, bin_session_id, pmsg2)
if ready_nicks and cmsg2 and ext_recovery:
for nick in ready_nicks:
self.dkg_cmsg2(nick, session_id, cmsg2, ext_recovery)
return {'accepted': True}
def dkg_cmsg2(self, nick, session_id, cmsg2, ext_recovery):
d = self.callRemote(commands.JMDKGCMsg2,
nick=nick, session_id=session_id,
cmsg2=base64.b64encode(cmsg2).decode('ascii'),
ext_recovery=ext_recovery.decode('ascii'))
self.defaultCallbacks(d)
@commands.JMDKGFinalizedSeen.responder
def on_JM_DKG_FINALIZED_SEEN(self, nick, session_id):
wallet = self.client.wallet_service.wallet
if not isinstance(wallet, FrostWallet) or wallet._dkg is None:
return {'accepted': True}
client = self.factory.client
bin_session_id = hextobin(session_id)
jlog.debug('Coordinator get dkgfinalized')
client.on_dkg_finalized(nick, bin_session_id)
return {'accepted': True}
@commands.JMDKGCMsg1Seen.responder
def on_JM_DKG_CMSG1_SEEN(self, nick, session_id, cmsg1):
wallet = self.client.wallet_service.wallet
if not isinstance(wallet, FrostWallet) or wallet._dkg is None:
return {'accepted': True}
client = self.factory.client
bin_session_id = hextobin(session_id)
session = client.dkg_sessions.get(bin_session_id)
if not session:
jlog.error(f'on_JM_DKG_CMSG1_SEEN: session {session_id} not found')
return {'accepted': True}
if session and session.coord_nick == nick:
cmsg1 = client.deserialize_cmsg1(base64.b64decode(cmsg1))
pmsg2 = client.party_step2(bin_session_id, cmsg1)
if pmsg2:
pmsg2b64 = base64.b64encode(pmsg2).decode('ascii')
d = self.callRemote(commands.JMDKGPMsg2,
nick=nick, session_id=session_id,
pmsg2=pmsg2b64)
self.defaultCallbacks(d)
else:
jlog.error(f'on_JM_DKG_CMSG1_SEEN: not coordinator nick {nick}')
return {'accepted': True}
@commands.JMDKGCMsg2Seen.responder
def on_JM_DKG_CMSG2_SEEN(self, nick, session_id, cmsg2, ext_recovery):
wallet = self.client.wallet_service.wallet
if not isinstance(wallet, FrostWallet) or wallet._dkg is None:
return {'accepted': True}
client = self.factory.client
bin_session_id = hextobin(session_id)
session = client.dkg_sessions.get(bin_session_id)
if not session:
jlog.error(f'on_JM_DKG_CMSG2_SEEN: session {session_id} not found')
return {'accepted': True}
if session and session.coord_nick == nick:
cmsg2 = client.deserialize_cmsg2(base64.b64decode(cmsg2))
finalized = client.finalize(bin_session_id, cmsg2,
ext_recovery.encode('ascii'))
if finalized:
d = self.callRemote(commands.JMDKGFinalized,
nick=nick, session_id=session_id)
self.defaultCallbacks(d)
else:
jlog.error(f'on_JM_DKG_CMSG2_SEEN: not coordinator nick {nick}')
return {'accepted': True}
"""FROST specifics
"""
def frost_req(self, dkg_session_id, msg_bytes):
jlog.debug('Coordinator call frost_req')
client = self.factory.client
hostpubkeyhash, sig, session_id = client.frost_req(
dkg_session_id, msg_bytes)
coordinator = client.frost_coordinators.get(session_id)
session = client.frost_sessions.get(session_id)
if session_id and session and coordinator:
d = self.callRemote(commands.JMFROSTReq,
hostpubkeyhash=hostpubkeyhash, sig=sig,
session_id=bintohex(session_id))
self.defaultCallbacks(d)
coordinator.frost_req_sec = time.time()
return session_id, coordinator, session
return None, None, None
@commands.JMFROSTReqSeen.responder
def on_JM_FROST_REQ_SEEN(self, nick, hostpubkeyhash, sig, session_id):
wallet = self.client.wallet_service.wallet
if not isinstance(wallet, FrostWallet) or wallet._dkg is None:
return {'accepted': True}
client = self.factory.client
session_id = hextobin(session_id)
nick, hostpubkeyhash, sig, session_id = \
client.on_frost_req(nick, hostpubkeyhash, sig, session_id)
if sig:
d = self.callRemote(commands.JMFROSTAck,
nick=nick,
hostpubkeyhash=hostpubkeyhash, sig=sig,
session_id=session_id)
self.defaultCallbacks(d)
return {'accepted': True}
@commands.JMFROSTAckSeen.responder
def on_JM_FROST_ACK_SEEN(self, nick, hostpubkeyhash, sig, session_id):
wallet = self.client.wallet_service.wallet
if not isinstance(wallet, FrostWallet) or wallet._dkg is None:
return {'accepted': True}
client = self.factory.client
bin_session_id = hextobin(session_id)
if client.on_frost_ack(nick, hostpubkeyhash, sig, bin_session_id):
d = self.callRemote(commands.JMFROSTInit,
nick=nick, session_id=session_id)
self.defaultCallbacks(d)
return {'accepted': True}
@commands.JMFROSTInitSeen.responder
def on_JM_FROST_INIT_SEEN(self, nick, session_id):
wallet = self.client.wallet_service.wallet
if not isinstance(wallet, FrostWallet) or wallet._dkg is None:
return {'accepted': True}
client = self.factory.client
session_id = hextobin(session_id)
nick, session_id, pub_nonce = client.on_frost_init(nick, session_id)
if pub_nonce:
pub_nonce_b64 = base64.b64encode(pub_nonce).decode('ascii')
d = self.callRemote(commands.JMFROSTRound1,
nick=nick, session_id=session_id,
pub_nonce=pub_nonce_b64)
self.defaultCallbacks(d)
return {'accepted': True}
@commands.JMFROSTRound1Seen.responder
def on_JM_FROST_ROUND1_SEEN(self, nick, session_id, pub_nonce):
wallet = self.client.wallet_service.wallet
if not isinstance(wallet, FrostWallet) or wallet._dkg is None:
return {'accepted': True}
client = self.factory.client
bin_session_id = hextobin(session_id)
pub_nonce = base64.b64decode(pub_nonce)
ready_nicks, nonce_agg, dkg_session_id, ids, msg = \
client.on_frost_round1(nick, bin_session_id, pub_nonce)
if ready_nicks and nonce_agg:
for nick in ready_nicks:
self.frost_agg1(nick, session_id, nonce_agg,
dkg_session_id, ids, msg)
return {'accepted': True}
def frost_agg1(self, nick, session_id,
nonce_agg, dkg_session_id, ids, msg):
nonce_agg = base64.b64encode(nonce_agg).decode('ascii')
dkg_session_id = base64.b64encode(dkg_session_id).decode('ascii')
ids = ','.join([str(i)for i in ids])
msg = base64.b64encode(msg).decode('ascii')
d = self.callRemote(commands.JMFROSTAgg1,
nick=nick, session_id=session_id,
nonce_agg=nonce_agg, dkg_session_id=dkg_session_id,
ids=ids, msg=msg)
self.defaultCallbacks(d)
@commands.JMFROSTAgg1Seen.responder
def on_JM_FROST_AGG1_SEEN(self, nick, session_id,
nonce_agg, dkg_session_id, ids, msg):
wallet = self.client.wallet_service.wallet
if not isinstance(wallet, FrostWallet) or wallet._dkg is None:
return {'accepted': True}
client = self.factory.client
bin_session_id = hextobin(session_id)
session = client.frost_sessions.get(bin_session_id)
if not session:
jlog.error(f'on_JM_DKG_AGG1_SEEN: session {session_id} not found')
return {'accepted': True}
if session and session.coord_nick == nick:
nonce_agg = base64.b64decode(nonce_agg)
dkg_session_id = base64.b64decode(dkg_session_id)
ids = [int(i) for i in ids.split(',')]
msg = base64.b64decode(msg)
partial_sig = client.frost_round2(
bin_session_id, nonce_agg, dkg_session_id, ids, msg)
if partial_sig:
partial_sig = base64.b64encode(partial_sig).decode('ascii')
d = self.callRemote(commands.JMFROSTRound2,
nick=nick, session_id=session_id,
partial_sig=partial_sig)
self.defaultCallbacks(d)
else:
jlog.error(f'on_JM_DKG_AGG1_SEEN: not coordinator nick {nick}')
return {'accepted': True}
@commands.JMFROSTRound2Seen.responder
def on_JM_FROST_ROUND2_SEEN(self, nick, session_id, partial_sig):
wallet = self.client.wallet_service.wallet
if not isinstance(wallet, FrostWallet) or wallet._dkg is None:
return {'accepted': True}
client = self.factory.client
bin_session_id = hextobin(session_id)
partial_sig = base64.b64decode(partial_sig)
sig = client.on_frost_round2(nick, bin_session_id, partial_sig)
if sig:
jlog.debug(f'Successfully get signature {sig.hex()[:8]}...')
return {'accepted': True}
class JMMakerClientProtocol(JMClientProtocol):
def __init__(self, factory, maker, nick_priv=None):
self.factory = factory
@ -395,11 +734,14 @@ class JMMakerClientProtocol(JMClientProtocol):
@commands.JMUp.responder
def on_JM_UP(self):
#wait until ready locally to submit offers (can be delayed
#if wallet sync is slow).
self.offers_ready_loop_counter = 0
self.offers_ready_loop = task.LoopingCall(self.submitOffers)
self.offers_ready_loop.start(2.0)
if isinstance(self.client, DKGClient):
self.client.on_jm_up()
if isinstance(self.client, Maker):
# wait until ready locally to submit offers (can be delayed
# if wallet sync is slow).
self.offers_ready_loop_counter = 0
self.offers_ready_loop = task.LoopingCall(self.submitOffers)
self.offers_ready_loop.start(2.0)
return {'accepted': True}
def submitOffers(self):
@ -461,10 +803,10 @@ class JMMakerClientProtocol(JMClientProtocol):
return {"accepted": True}
@commands.JMAuthReceived.responder
def on_JM_AUTH_RECEIVED(self, nick, offer, commitment, revelation, amount,
async def on_JM_AUTH_RECEIVED(self, nick, offer, commitment, revelation, amount,
kphex):
retval = self.client.on_auth_received(nick, offer,
commitment, revelation, amount, kphex)
retval = await self.client.on_auth_received(
nick, offer, commitment, revelation, amount, kphex)
if not retval[0]:
jlog.info("Maker refuses to continue on receiving auth.")
else:
@ -488,8 +830,8 @@ class JMMakerClientProtocol(JMClientProtocol):
return {"accepted": True}
@commands.JMTXReceived.responder
def on_JM_TX_RECEIVED(self, nick, tx, offer):
retval = self.client.on_tx_received(nick, tx, offer)
async def on_JM_TX_RECEIVED(self, nick, tx, offer):
retval = await self.client.on_tx_received(nick, tx, offer)
if not retval[0]:
jlog.info("Maker refuses to continue on receipt of tx")
else:
@ -621,7 +963,7 @@ class JMTakerClientProtocol(JMClientProtocol):
blacklist_location=jm_single().commitment_list_location)
self.defaultCallbacks(d)
def stallMonitor(self, schedule_index):
async def stallMonitor(self, schedule_index):
"""Diagnoses whether long wait is due to any kind of failure;
if so, calls the taker on_finished_callback with a failure
flag so that the transaction can be re-tried or abandoned, as desired.
@ -645,7 +987,10 @@ class JMTakerClientProtocol(JMClientProtocol):
if not self.client.txid:
#txid is set on pushing; if it's not there, we have failed.
jlog.info("Stall detected. Retrying transaction if possible ...")
self.client.on_finished_callback(False, True, 0.0)
finished_cb_res = self.client.on_finished_callback(
False, True, 0.0)
if asyncio.iscoroutine(finished_cb_res):
finished_cb_res = await finished_cb_res
else:
#This shouldn't really happen; if the tx confirmed,
#the finished callback should already be called.
@ -670,7 +1015,7 @@ class JMTakerClientProtocol(JMClientProtocol):
return {'accepted': True}
@commands.JMFillResponse.responder
def on_JM_FILL_RESPONSE(self, success, ioauth_data):
async def on_JM_FILL_RESPONSE(self, success, ioauth_data):
"""Receives the entire set of phase 1 data (principally utxos)
from the counterparties and passes through to the Taker for
tx construction. If there were sufficient makers, data is passed
@ -689,14 +1034,17 @@ class JMTakerClientProtocol(JMClientProtocol):
return {'accepted': True}
else:
jlog.info("Makers responded with: " + str(ioauth_data))
retval = self.client.receive_utxos(ioauth_data)
retval = await self.client.receive_utxos(ioauth_data)
if not retval[0]:
jlog.info("Taker is not continuing, phase 2 abandoned.")
jlog.info("Reason: " + str(retval[1]))
if len(self.client.schedule) == 1:
# see comment for the same invocation in on_JM_OFFERS;
# the logic here is the same.
self.client.on_finished_callback(False, False, 0.0)
finished_cb_res = self.client.on_finished_callback(
False, False, 0.0)
if asyncio.iscoroutine(finished_cb_res):
finished_cb_res = await finished_cb_res
return {'accepted': False}
else:
nick_list, tx = retval[1:]
@ -704,12 +1052,13 @@ class JMTakerClientProtocol(JMClientProtocol):
return {'accepted': True}
@commands.JMOffers.responder
def on_JM_OFFERS(self, orderbook, fidelitybonds):
async def on_JM_OFFERS(self, orderbook, fidelitybonds):
self.orderbook = json.loads(orderbook)
fidelity_bonds_list = json.loads(fidelitybonds)
#Removed for now, as judged too large, even for DEBUG:
#jlog.debug("Got the orderbook: " + str(self.orderbook))
retval = self.client.initialize(self.orderbook, fidelity_bonds_list)
retval = await self.client.initialize(
self.orderbook, fidelity_bonds_list)
#format of retval is:
#True, self.cjamount, commitment, revelation, self.filtered_orderbook)
if not retval[0]:
@ -718,12 +1067,18 @@ class JMTakerClientProtocol(JMClientProtocol):
#In single sendpayments, allow immediate quit.
#This could be an optional feature also for multi-entry schedules,
#but is not the functionality desired in general (tumbler).
self.client.on_finished_callback(False, False, 0.0)
finished_cb_res = self.client.on_finished_callback(
False, False, 0.0)
if asyncio.iscoroutine(finished_cb_res):
finished_cb_res = await finished_cb_res
return {'accepted': True}
elif retval[0] == "commitment-failure":
#This case occurs if we cannot find any utxos for reasons
#other than age, which is a permanent failure
self.client.on_finished_callback(False, False, 0.0)
finished_cb_res = self.client.on_finished_callback(
False, False, 0.0)
if asyncio.iscoroutine(finished_cb_res):
finished_cb_res = await finished_cb_res
return {'accepted': True}
amt, cmt, rev, foffers = retval[1:]
d = self.callRemote(commands.JMFill,
@ -735,8 +1090,8 @@ class JMTakerClientProtocol(JMClientProtocol):
return {'accepted': True}
@commands.JMSigReceived.responder
def on_JM_SIG_RECEIVED(self, nick, sig):
retval = self.client.on_sig(nick, sig)
async def on_JM_SIG_RECEIVED(self, nick, sig):
retval = await self.client.on_sig(nick, sig)
if retval:
nick_to_use, tx = retval
self.push_tx(nick_to_use, tx)
@ -852,12 +1207,13 @@ def start_reactor(host, port, factory=None, snickerfactory=None,
name, str(p[0] - port_offset)))
break
except Exception:
jlog.warn("Cannot listen on port " + str(
p[0] - port_offset) + ", trying next port")
jlog.warning("Cannot listen on port " +
str(p[0] - port_offset) +
", trying next port")
if p[0] >= (orgp + 100):
jlog.error("Tried 100 ports but cannot "
"listen on any of them. Quitting.")
sys.exit(EXIT_FAILURE)
twisted_sys_exit(EXIT_FAILURE)
p[0] += 1
return (p[0], serverconn)

9
src/jmclient/commitment_utils.py

@ -1,12 +1,13 @@
# -*- coding: utf-8 -*-
import sys
from jmbase import jmprint, utxostr_to_utxo, utxo_to_utxostr, EXIT_FAILURE
from jmbase import (jmprint, utxostr_to_utxo, utxo_to_utxostr, EXIT_FAILURE,
twisted_sys_exit)
from jmclient import jm_single, BTCEngine, BTC_P2PKH, BTC_P2SH_P2WPKH, BTC_P2WPKH
def quit(parser, errmsg): #pragma: no cover
parser.error(errmsg)
sys.exit(EXIT_FAILURE)
twisted_sys_exit(EXIT_FAILURE)
def get_utxo_info(upriv, utxo_binary=False):
"""Verify that the input string parses correctly as (utxo, priv)
@ -52,7 +53,7 @@ def validate_utxo_data(utxo_datas, retrieve=False, utxo_address_type="p2wpkh"):
success, utxostr = utxo_to_utxostr(u)
if not success:
jmprint("Invalid utxo format: " + str(u), "error")
sys.exit(EXIT_FAILURE)
twisted_sys_exit(EXIT_FAILURE)
jmprint('validating this utxo: ' + utxostr, "info")
# as noted in `ImportWalletMixin` code comments, there is not
# yet a functional auto-detection of key type from WIF, hence

80
src/jmclient/configure.py

@ -13,7 +13,8 @@ from typing import Any, List, Optional, Tuple
import jmbitcoin as btc
from jmbase.support import (get_log, joinmarket_alert, core_alert, debug_silence,
set_logging_level, jmprint, set_logging_color,
JM_APP_NAME, lookup_appdata_folder, EXIT_FAILURE)
JM_APP_NAME, lookup_appdata_folder, EXIT_FAILURE,
twisted_sys_exit)
from jmclient.jsonrpc import JsonRpc
from jmclient.podle import set_commitment_file
@ -45,8 +46,7 @@ class AttributeDict(object):
logFormatter = logging.Formatter(
('%(asctime)s [%(threadName)-12.12s] '
'[%(levelname)-5.5s] %(message)s'))
logsdir = os.path.join(os.path.dirname(
global_singleton.config_location), "logs")
logsdir = os.path.join(global_singleton.datadir, "logs")
fileHandler = logging.FileHandler(
logsdir + '/{}.log'.format(value))
fileHandler.setFormatter(logFormatter)
@ -76,7 +76,7 @@ global_singleton.joinmarket_alert = joinmarket_alert
global_singleton.debug_silence = debug_silence
global_singleton.config = ConfigParser(strict=False)
#This is reset to a full path after load_program_config call
global_singleton.config_location = 'joinmarket.cfg'
global_singleton.config_fname = 'joinmarket.cfg'
#as above
global_singleton.commit_file_location = 'cmtdata/commitments.json'
global_singleton.wait_for_commitments = 0
@ -121,12 +121,13 @@ use_ssl = false
# to Bitcoin Core; note that use of this option for any other purpose is currently unsupported.
blockchain_source = bitcoin-rpc
# options: signet, testnet, mainnet
# options: signet, testnet, testnet4, mainnet
# Note: for regtest, use network = testnet
network = mainnet
rpc_host = localhost
# default ports are 8332 for mainnet, 18443 for regtest, 18332 for testnet, 38332 for signet
# default ports are 8332 for mainnet, 18443 for regtest, 18332 for testnet,
# 38332 for signet, 48332 for testnet4
rpc_port =
# Use either rpc_user / rpc_password pair or rpc_cookie_file.
@ -223,6 +224,12 @@ confirm_timeout_hours = 6
# Only set to false for old wallets, Joinmarket is now segwit only.
segwit = true
# Use Taproot P2TR SegWit wallet
#taproot = true
# Use FROST P2TR SegWit wallet
#frost = true
# Use native segwit (bech32) wallet. If set to false, p2sh-p2wkh
# will be used when generating the addresses for this wallet.
# Notes: 1. The default joinmarket pit is native segwit.
@ -691,17 +698,19 @@ def load_program_config(config_path: str = "", bs: Optional[str] = None,
os.makedirs(os.path.join(global_singleton.datadir, "logs"))
if not os.path.exists(os.path.join(global_singleton.datadir, "cmtdata")):
os.makedirs(os.path.join(global_singleton.datadir, "cmtdata"))
global_singleton.config_location = os.path.join(
global_singleton.datadir, global_singleton.config_location)
config_location = os.path.join(
global_singleton.datadir, global_singleton.config_fname)
_remove_unwanted_default_settings(global_singleton.config)
try:
loadedFiles = global_singleton.config.read(
[global_singleton.config_location])
loadedFiles = global_singleton.config.read([config_location])
except UnicodeDecodeError:
jmprint("Error loading `joinmarket.cfg`, invalid file format.",
"info")
sys.exit(EXIT_FAILURE)
twisted_sys_exit(EXIT_FAILURE)
if get_network() == 'mainnet':
jmprint("Running on mainnet is blocked for beta code", "info")
twisted_sys_exit(EXIT_FAILURE)
# Hack required for bitcoin-rpc-no-history and probably others
# (historicaly electrum); must be able to enforce a different blockchain
@ -710,11 +719,11 @@ def load_program_config(config_path: str = "", bs: Optional[str] = None,
global_singleton.config.set("BLOCKCHAIN", "blockchain_source", bs)
# Create default config file if not found
if len(loadedFiles) != 1:
with open(global_singleton.config_location, "w") as configfile:
with open(config_location, "w") as configfile:
configfile.write(defaultconfig)
jmprint("Created a new `joinmarket.cfg`. Please review and adopt the "
"settings and restart joinmarket.", "info")
sys.exit(EXIT_FAILURE)
twisted_sys_exit(EXIT_FAILURE)
loglevel = global_singleton.config.get("LOGGING", "console_log_level")
try:
@ -751,7 +760,7 @@ def load_program_config(config_path: str = "", bs: Optional[str] = None,
"location cmtdata/commitments.json")
if get_network() != "mainnet":
# no need to be flexible for tests; note this is used
# for regtest, signet and testnet3
# for regtest, signet, testnet3 and testnet4
global_singleton.commit_file_location = "cmtdata/" + get_network() + \
"_commitments.json"
set_commitment_file(os.path.join(config_path,
@ -780,8 +789,7 @@ def load_program_config(config_path: str = "", bs: Optional[str] = None,
# and setting that in the plugin object; the plugin
# itself will switch on its own logging when ready,
# attaching a filehandler to the global log.
plogsdir = os.path.join(os.path.dirname(
global_singleton.config_location), "logs", p.name)
plogsdir = os.path.join(global_singleton.datadir, "logs", p.name)
if not os.path.exists(plogsdir):
os.makedirs(plogsdir)
p.set_log_dir(plogsdir)
@ -844,7 +852,8 @@ def _get_bitcoin_rpc_credentials(_config: ConfigParser) -> Tuple[str, str]:
raise ValueError("Invalid RPC auth credentials `rpc_user` and `rpc_password`")
return rpc_user, rpc_password
def get_blockchain_interface_instance(_config: ConfigParser):
def get_blockchain_interface_instance(_config: ConfigParser, *,
rpc_wallet_name=None):
# todo: refactor joinmarket module to get rid of loops
# importing here is necessary to avoid import loops
from jmclient.blockchaininterface import BitcoinCoreInterface, \
@ -852,7 +861,7 @@ def get_blockchain_interface_instance(_config: ConfigParser):
BitcoinCoreNoHistoryInterface
source = _config.get("BLOCKCHAIN", "blockchain_source")
network = get_network()
testnet = (network == 'testnet' or network == 'signet')
testnet = (network in ['testnet', 'testnet4', 'signet'])
if source in ('bitcoin-rpc', 'regtest', 'bitcoin-rpc-no-history'):
rpc_host = _config.get("BLOCKCHAIN", "rpc_host")
@ -864,13 +873,28 @@ def get_blockchain_interface_instance(_config: ConfigParser):
rpc_port = 18443
elif network == 'testnet':
rpc_port = 18332
elif network == 'testnet4':
rpc_port = 48332
elif network == 'signet':
rpc_port = 38332
else:
raise ValueError('wrong network configured: ' + network)
rpc_user, rpc_password = _get_bitcoin_rpc_credentials(_config)
rpc_wallet_file = _config.get("BLOCKCHAIN", "rpc_wallet_file")
if rpc_wallet_name is not None:
rpc_wallet_file = rpc_wallet_name
else:
rpc_wallet_file = _config.get("BLOCKCHAIN", "rpc_wallet_file")
rpc = JsonRpc(rpc_host, rpc_port, rpc_user, rpc_password)
# code for TaprootWallet testing
if rpc_wallet_name and source in ['bitcoin-rpc', 'regtest',
'bitcoin-rpc-no-history']:
# create wallet with disable_private_keys=True, blank=True
rpc.call('createwallet', [rpc_wallet_name, True, True])
loaded_wallets = rpc.call("listwallets", [])
if not rpc_wallet_name in loaded_wallets:
log.info(f"Loading Bitcoin RPC wallet {rpc_wallet_name }...")
rpc.call('loadwallet', [rpc_wallet_name])
log.info("Done.")
if source == 'bitcoin-rpc': #pragma: no cover
bc_interface = BitcoinCoreInterface(rpc, network,
rpc_wallet_file)
@ -926,7 +950,9 @@ def update_persist_config(section: str, name: str, value: Any) -> bool:
sectionname = None
newlines = []
match_found = False
with open(jm_single().config_location, "r") as f:
config_location = os.path.join(jm_single().datadir,
jm_single().config_fname)
with open(config_location, "r") as f:
for line in f.readlines():
newline = line
# ignore comment lines
@ -949,10 +975,22 @@ def update_persist_config(section: str, name: str, value: Any) -> bool:
return False
# success: update in-mem and re-persist
jm_single().config.set(section, name, value)
with open(jm_single().config_location, "wb") as f:
with open(config_location, "wb") as f:
f.writelines([x.encode("utf-8") for x in newlines])
return True
def is_frost_mode() -> bool:
c = jm_single().config
if not c.has_option('POLICY', 'frost'):
return False
return c.get('POLICY', 'frost') != 'false'
def is_taproot_mode() -> bool:
c = jm_single().config
if not c.has_option('POLICY', 'taproot'):
return False
return c.get('POLICY', 'taproot') != 'false'
def is_segwit_mode() -> bool:
return jm_single().config.get('POLICY', 'segwit') != 'false'

129
src/jmclient/cryptoengine.py

@ -2,6 +2,8 @@
from collections import OrderedDict
import struct
from bitcointx.core.script import SignatureHashSchnorr
import jmbitcoin as btc
from jmbase import bintohex
from .configure import get_network, jm_single
@ -16,12 +18,16 @@ from .configure import get_network, jm_single
# make existing wallets unsable.
TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, TYPE_P2SH_M_N, TYPE_TIMELOCK_P2WSH, \
TYPE_SEGWIT_WALLET_FIDELITY_BONDS, TYPE_WATCHONLY_FIDELITY_BONDS, \
TYPE_WATCHONLY_TIMELOCK_P2WSH, TYPE_WATCHONLY_P2WPKH, TYPE_P2WSH, TYPE_P2TR = range(11)
NET_MAINNET, NET_TESTNET, NET_SIGNET = range(3)
TYPE_WATCHONLY_TIMELOCK_P2WSH, TYPE_WATCHONLY_P2WPKH, TYPE_P2WSH, \
TYPE_P2TR, TYPE_P2TR_FROST, TYPE_TAPROOT_WALLET_FIDELITY_BONDS, \
TYPE_TAPROOT_WATCHONLY_FIDELITY_BONDS, TYPE_WATCHONLY_P2TR, = range(15)
NET_MAINNET, NET_TESTNET, NET_SIGNET, NET_TESTNET4 = range(4)
NET_MAP = {'mainnet': NET_MAINNET, 'testnet': NET_TESTNET,
'signet': NET_SIGNET}
WIF_PREFIX_MAP = {'mainnet': b'\x80', 'testnet': b'\xef', 'signet': b'\xef'}
BIP44_COIN_MAP = {'mainnet': 2**31, 'testnet': 2**31 + 1, 'signet': 2**31 + 1}
'signet': NET_SIGNET, 'testnet4': NET_TESTNET4}
WIF_PREFIX_MAP = {'mainnet': b'\x80', 'testnet': b'\xef', 'signet': b'\xef',
'testnet4': b'\xef'}
BIP44_COIN_MAP = {'mainnet': 2**31, 'testnet': 2**31 + 1, 'signet': 2**31 + 1,
'testnet4': 2**31 + 1}
BIP32_PUB_PREFIX = "xpub"
BIP49_PUB_PREFIX = "ypub"
@ -206,6 +212,10 @@ class BTCEngine(object):
def pubkey_to_script(cls, pubkey):
raise NotImplementedError()
@classmethod
def output_pubkey_to_script(cls, pubkey):
raise NotImplementedError()
@classmethod
def privkey_to_address(cls, privkey):
script = cls.key_to_script(privkey)
@ -236,7 +246,17 @@ class BTCEngine(object):
return script == pscript
@classmethod
def sign_transaction(cls, tx, index, privkey, amount):
def output_pubkey_has_script(cls, pubkey, script):
stype = detect_script_type(script)
assert stype in ENGINES
engine = ENGINES[stype]
if engine is None:
raise EngineError
pscript = engine.output_pubkey_to_script(pubkey)
return script == pscript
@classmethod
async def sign_transaction(cls, tx, index, privkey, amount):
raise NotImplementedError()
@staticmethod
@ -281,7 +301,7 @@ class BTC_P2PKH(BTCEngine):
raise EngineError("Script code does not apply to legacy wallets")
@classmethod
def sign_transaction(cls, tx, index, privkey, *args, **kwargs):
async def sign_transaction(cls, tx, index, privkey, *args, **kwargs):
hashcode = kwargs.get('hashcode') or btc.SIGHASH_ALL
return btc.sign(tx, index, privkey,
hashcode=hashcode, amount=None, native=False)
@ -308,7 +328,7 @@ class BTC_P2SH_P2WPKH(BTCEngine):
return btc.pubkey_to_p2pkh_script(pubkey, require_compressed=True)
@classmethod
def sign_transaction(cls, tx, index, privkey, amount,
async def sign_transaction(cls, tx, index, privkey, amount,
hashcode=btc.SIGHASH_ALL, **kwargs):
assert amount is not None
a, b = btc.sign(tx, index, privkey,
@ -345,7 +365,7 @@ class BTC_P2WPKH(BTCEngine):
return btc.pubkey_to_p2pkh_script(pubkey, require_compressed=True)
@classmethod
def sign_transaction(cls, tx, index, privkey, amount,
async def sign_transaction(cls, tx, index, privkey, amount,
hashcode=btc.SIGHASH_ALL, **kwargs):
assert amount is not None
return btc.sign(tx, index, privkey,
@ -394,7 +414,7 @@ class BTC_Timelocked_P2WSH(BTCEngine):
return btc.bin_to_b58check(priv, cls.WIF_PREFIX)
@classmethod
def sign_transaction(cls, tx, index, privkey_locktime, amount,
async def sign_transaction(cls, tx, index, privkey_locktime, amount,
hashcode=btc.SIGHASH_ALL, **kwargs):
assert amount is not None
priv, locktime = privkey_locktime
@ -427,7 +447,7 @@ class BTC_Watchonly_Timelocked_P2WSH(BTC_Timelocked_P2WSH):
return ""
@classmethod
def sign_transaction(cls, tx, index, privkey, amount,
async def sign_transaction(cls, tx, index, privkey, amount,
hashcode=btc.SIGHASH_ALL, **kwargs):
raise RuntimeError("Cannot spend from watch-only wallets")
@ -454,17 +474,100 @@ class BTC_Watchonly_P2WPKH(BTC_P2WPKH):
master_key, BTC_Watchonly_Timelocked_P2WSH.get_watchonly_path(path))
@classmethod
def sign_transaction(cls, tx, index, privkey, amount,
async def sign_transaction(cls, tx, index, privkey, amount,
hashcode=btc.SIGHASH_ALL, **kwargs):
raise RuntimeError("Cannot spend from watch-only wallets")
class BTC_P2TR(BTCEngine):
@classproperty
def VBYTE(cls):
return btc.BTC_P2TR_VBYTE[get_network()]
@classmethod
def pubkey_to_script(cls, pubkey):
return btc.pubkey_to_p2tr_script(pubkey)
@classmethod
def output_pubkey_to_script(cls, pubkey):
return btc.output_pubkey_to_p2tr_script(pubkey)
@classmethod
def pubkey_to_script_code(cls, pubkey):
raise NotImplementedError()
@classmethod
async def sign_transaction(cls, tx, index, privkey, amount,
hashcode=btc.SIGHASH_ALL, **kwargs):
assert amount is not None
spent_outputs = kwargs['spent_outputs']
return btc.sign(tx, index, privkey,
hashcode=hashcode, amount=amount, native="p2tr",
spent_outputs=spent_outputs)
class BTC_Watchonly_P2TR(BTC_P2TR):
@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_P2TR, cls).derive_bip32_pub_export(
master_key, BTC_Watchonly_Timelocked_P2WSH.get_watchonly_path(path))
@classmethod
async def sign_transaction(cls, tx, index, privkey, amount,
hashcode=btc.SIGHASH_ALL, **kwargs):
raise RuntimeError("Cannot spend from watch-only wallets")
class BTC_P2TR_FROST(BTC_P2TR):
@classmethod
async def sign_transaction(cls, tx, i, path, amount,
hashcode=btc.SIGHASH_ALL, wallet=None,
**kwargs):
spent_outputs = kwargs['spent_outputs']
sighash = SignatureHashSchnorr(tx, i, spent_outputs)
mixdepth, address_type, index = wallet.get_details(path)
sig, pubkey, tweaked_pubkey = await wallet.ipc_client.frost_req(
mixdepth, address_type, index, sighash)
if not sig:
return None, "FROST signing failed"
sig, msg = btc.add_frost_sig(tx, i, pubkey, sig, amount,
spent_outputs=spent_outputs)
return sig, msg
ENGINES = {
TYPE_P2PKH: BTC_P2PKH,
TYPE_P2SH_P2WPKH: BTC_P2SH_P2WPKH,
TYPE_P2WPKH: BTC_P2WPKH,
TYPE_TIMELOCK_P2WSH: BTC_Timelocked_P2WSH,
TYPE_WATCHONLY_TIMELOCK_P2WSH: BTC_Watchonly_Timelocked_P2WSH,
TYPE_WATCHONLY_P2WPKH: BTC_Watchonly_P2WPKH,
TYPE_SEGWIT_WALLET_FIDELITY_BONDS: BTC_P2WPKH,
TYPE_P2TR: None # TODO
TYPE_P2TR: BTC_P2TR,
TYPE_WATCHONLY_P2TR: BTC_Watchonly_P2TR,
TYPE_TAPROOT_WALLET_FIDELITY_BONDS: BTC_P2TR,
TYPE_P2TR_FROST: BTC_P2TR_FROST,
}

49
src/jmclient/descriptor.py

@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "
CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
GENERATOR = [0xf5dee51989, 0xa9fdca3312, 0x1bab10e32d, 0x3706b1677a, 0x644d626ffd]
def descsum_polymod(symbols):
"""Internal function that computes the descriptor checksum."""
chk = 1
for value in symbols:
top = chk >> 35
chk = (chk & 0x7ffffffff) << 5 ^ value
for i in range(5):
chk ^= GENERATOR[i] if ((top >> i) & 1) else 0
return chk
def descsum_expand(s):
"""Internal function that does the character to symbol expansion"""
groups = []
symbols = []
for c in s:
if not c in INPUT_CHARSET:
return None
v = INPUT_CHARSET.find(c)
symbols.append(v & 31)
groups.append(v >> 5)
if len(groups) == 3:
symbols.append(groups[0] * 9 + groups[1] * 3 + groups[2])
groups = []
if len(groups) == 1:
symbols.append(groups[0])
elif len(groups) == 2:
symbols.append(groups[0] * 3 + groups[1])
return symbols
def descsum_check(s):
"""Verify that the checksum is correct in a descriptor"""
if s[-9] != '#':
return False
if not all(x in CHECKSUM_CHARSET for x in s[-8:]):
return False
symbols = descsum_expand(s[:-9]) + [CHECKSUM_CHARSET.find(x) for x in s[-8:]]
return descsum_polymod(symbols) == 1
def descsum_create(s):
"""Add a checksum to a descriptor without"""
symbols = descsum_expand(s) + [0, 0, 0, 0, 0, 0, 0, 0]
checksum = descsum_polymod(symbols) ^ 1
return s + '#' + ''.join(CHECKSUM_CHARSET[(checksum >> (5 * (7 - i))) & 31] for i in range(8))

1050
src/jmclient/frost_clients.py

File diff suppressed because it is too large Load Diff

275
src/jmclient/frost_ipc.py

@ -0,0 +1,275 @@
# -*- coding: utf-8 -*-
import asyncio
import pickle
import jmbitcoin as btc
from jmbase.support import jmprint, EXIT_FAILURE, twisted_sys_exit, get_log
jlog = get_log()
class IPCBase:
def encrypt_msg(self, msg_dict):
msg_bytes = pickle.dumps(msg_dict)
return btc.ecies_encrypt(msg_bytes, self.pubkey) + b'\n'
def decrypt_msg(self, enc_bytes):
msg_bytes = btc.ecies_decrypt(self.wallet._hostseckey, enc_bytes)
return pickle.loads(msg_bytes)
class FrostIPCServer(IPCBase):
def __init__(self, wallet):
self.loop = asyncio.get_event_loop()
self.wallet = wallet
self.pubkey = btc.privkey_to_pubkey(wallet._hostseckey)
self.sock_path = f'{wallet._storage.get_location()}.sock'
self.srv = None
self.sr = None
self.sw = None
self.tasks = set()
async def async_init(self):
self.srv = await asyncio.start_unix_server(
self.handle_connection, self.sock_path)
async def serve_forever(self):
return await self.srv.serve_forever()
async def handle_connection(self, sr, sw):
if self.sr or self.sw:
jlog.error('FrostIPCServer.handle_connection: client '
'already connected, ignore other connection attempt')
return
jlog.info('FrostIPCServer.handle_connection: connected new client')
self.sr = sr
self.sw = sw
await self.process_msgs()
async def process_msgs(self):
while True:
try:
line_data = await self.sr.readline()
if not line_data:
if self.sr.at_eof():
jlog.info('FrostIPCServer.process_msg: '
'client disconnected')
self.sr = None
self.sw = None
while self.tasks:
task = self.tasks.pop()
task.cancel()
break
else:
jlog.error('FrostIPCServer.process_msg: '
'empty line ignored')
continue
enc_bytes = line_data.strip()
msg_dict = self.decrypt_msg(enc_bytes)
msg_id = msg_dict['msg_id']
cmd = msg_dict['cmd']
data = msg_dict['data']
task = None
if cmd == 'get_dkg_pubkey':
task = self.loop.create_task(
self.on_get_dkg_pubkey(msg_id, *data))
elif cmd == 'frost_req':
task = self.loop.create_task(
self.on_frost_req(msg_id, *data))
if task:
self.tasks.add(task)
except Exception as e:
jlog.error(f'FrostIPCServer.process_msgs: {repr(e)}')
await asyncio.sleep(0.1)
async def on_get_dkg_pubkey(self, msg_id, mixdepth, address_type, index,
session_id=None):
try:
wallet = self.wallet
dkg = wallet.dkg
if session_id is not None:
client = wallet.client_factory.getClient()
frost_client = wallet.client_factory.client
frost_client.dkg_gen_list.append(
(mixdepth, address_type, index))
new_pubkey = await client.dkg_gen(session_id=session_id)
else:
new_pubkey = dkg.find_dkg_pubkey(mixdepth, address_type, index)
if session_id is None and new_pubkey is None:
client = wallet.client_factory.getClient()
frost_client = wallet.client_factory.client
frost_client.dkg_gen_list.append(
(mixdepth, address_type, index))
await client.dkg_gen()
new_pubkey = dkg.find_dkg_pubkey(mixdepth, address_type, index)
if new_pubkey:
await self.send_dkg_pubkey(msg_id, new_pubkey)
else:
raise Exception('No pubkey found or generated')
except Exception as e:
await self.send_dkg_pubkey(msg_id, None)
jlog.error(f'FrostIPCServer.on_get_dkg_pubkey: {repr(e)}')
async def send_dkg_pubkey(self, msg_id, pubkey):
try:
msg_dict = {
'msg_id': msg_id,
'cmd': 'dkg_pubkey',
'data': pubkey,
}
self.sw.write(self.encrypt_msg(msg_dict))
await self.sw.drain()
except Exception as e:
jlog.error(f'FrostIPCServer.send_dkg_pubkey: {repr(e)}')
async def on_frost_req(self, msg_id, mixdepth, address_type, index,
sighash):
try:
wallet = self.wallet
client = wallet.client_factory.getClient()
frost_client = wallet.client_factory.client
dkg = wallet.dkg
dkg_session_id = dkg.find_session(mixdepth, address_type, index)
session_id, _, _ = client.frost_req(dkg_session_id, sighash)
sig, tweaked_pubkey = await frost_client.wait_on_sig(session_id)
pubkey = dkg.find_dkg_pubkey(mixdepth, address_type, index)
await self.send_frost_sig(msg_id, sig, pubkey, tweaked_pubkey)
except Exception as e:
jlog.error(f'FrostIPCServer.on_frost_req: {repr(e)}')
await self.send_frost_sig(msg_id, None, None, None)
async def send_frost_sig(self, msg_id, sig, pubkey, tweaked_pubkey):
try:
msg_dict = {
'msg_id': msg_id,
'cmd': 'frost_sig',
'data': (sig, pubkey, tweaked_pubkey),
}
self.sw.write(self.encrypt_msg(msg_dict))
await self.sw.drain()
except Exception as e:
jlog.error(f'FrostIPCServer.send_frost_sig: {repr(e)}')
class FrostIPCClient(IPCBase):
def __init__(self, wallet):
self.loop = asyncio.get_event_loop()
self.msg_id = 0
self.msg_futures = {}
self.wallet = wallet
self.pubkey = btc.privkey_to_pubkey(wallet._hostseckey)
self.sock_path = f'{wallet._storage.get_location()}.sock'
self.sr = None
self.sw = None
async def async_init(self):
try:
self.sr, self.sw = await asyncio.open_unix_connection(
self.sock_path)
self.loop.create_task(self.process_msgs())
except (FileNotFoundError, ConnectionRefusedError) as e:
jmprint('No servefrost socket found. Run wallet-tool.py '
'wallet.jmdat servefrost in separate console.', "error")
twisted_sys_exit(EXIT_FAILURE)
async def process_msgs(self):
while True:
try:
line_data = await self.sr.readline()
if not line_data:
if self.sr.at_eof():
jlog.info('FrostIPCClient.process_msg: '
'client disconnected')
self.sr = None
self.sw = None
for msg_id, fut in list(self.msg_futures.items()):
fut = self.msg_futures.pop(msg_id)
fut.cancel()
break
else:
jlog.error('FrostIPCClient.process_msg: '
'empty line ignored')
continue
enc_bytes = line_data.strip()
msg_dict = self.decrypt_msg(enc_bytes)
msg_id = msg_dict['msg_id']
cmd = msg_dict['cmd']
data = msg_dict['data']
if cmd in ['dkg_pubkey', 'frost_sig']:
await self.on_response(msg_id, data)
except Exception as e:
jlog.error(f'FrostIPCClient.process_msgs: {repr(e)}')
await asyncio.sleep(0.1)
async def on_response(self, msg_id, data):
fut = self.msg_futures.pop(msg_id, None)
if fut:
fut.set_result(data)
async def get_dkg_pubkey(self, mixdepth, address_type, index,
session_id=None):
jlog.debug(f'FrostIPCClient.get_dkg_pubkey for mixdepth={mixdepth}, '
f'address_type={address_type}, index={index}')
try:
self.msg_id += 1
msg_dict = {
'msg_id': self.msg_id,
'cmd': 'get_dkg_pubkey',
'data': (mixdepth, address_type, index, session_id),
}
self.sw.write(self.encrypt_msg(msg_dict))
await self.sw.drain()
fut = self.loop.create_future()
self.msg_futures[self.msg_id] = fut
await fut
pubkey = fut.result()
if pubkey is None:
jlog.error(
f'FrostIPCClient.get_dkg_pubkey got None pubkey from '
f'FrostIPCServer for mixdepth={mixdepth}, '
f'address_type={address_type}, index={index}')
return pubkey
jlog.debug(f'FrostIPCClient.get_dkg_pubkey successfully got '
f'pubkey for mixdepth={mixdepth}, '
f'address_type={address_type}, index={index}')
return pubkey
except Exception as e:
jlog.error(f'FrostIPCClient.get_dkg_pubkey: {repr(e)}')
async def frost_req(self, mixdepth, address_type, index, sighash):
jlog.debug(f'FrostIPCClient.frost_req for mixdepth={mixdepth}, '
f'address_type={address_type}, index={index}, '
f'sighash={sighash.hex()}')
try:
self.msg_id += 1
msg_dict = {
'msg_id': self.msg_id,
'cmd': 'frost_req',
'data': (mixdepth, address_type, index, sighash),
}
self.sw.write(self.encrypt_msg(msg_dict))
await self.sw.drain()
fut = self.loop.create_future()
self.msg_futures[self.msg_id] = fut
await fut
sig, pubkey, tweaked_pubkey = fut.result()
if sig is None:
jlog.error(
f'FrostIPCClient.frost_req got None sig value from '
f'FrostIPCServer for mixdepth={mixdepth}, '
f'address_type={address_type}, index={index}, '
f'sighash={sighash.hex()}')
return sig, pubkey, tweaked_pubkey
jlog.debug(
f'FrostIPCClient.frost_req successfully got signature '
f'for mixdepth={mixdepth}, address_type={address_type}, '
f'index={index}, sighash={sighash.hex()}')
return sig, pubkey, tweaked_pubkey
except Exception as e:
jlog.error(f'FrostIPCClient.frost_req: {repr(e)}')
return None, None, None

10
src/jmclient/jsonrpc.py

@ -108,17 +108,17 @@ class JsonRpc(object):
return "CONNFAILURE"
except socket.error as e:
if e.errno == errno.ECONNRESET:
jlog.warn('Connection was reset, attempting reconnect.')
jlog.warning('Connection was reset, attempting reconnect.')
self.conn.close()
self.conn.connect()
elif e.errno == errno.EPIPE:
jlog.warn('Connection had broken pipe, attempting '
'reconnect.')
jlog.warning('Connection had broken pipe, attempting '
'reconnect.')
self.conn.close()
self.conn.connect()
elif e.errno == errno.EPROTOTYPE:
jlog.warn('Connection had protocol wrong type for socket '
'error, attempting reconnect.')
jlog.warning('Connection had protocol wrong type for'
' socket error, attempting reconnect.')
self.conn.close()
self.conn.connect()
elif e.errno == errno.ECONNREFUSED:

75
src/jmclient/maker.py

@ -1,10 +1,14 @@
import base64
import sys
import hashlib
import abc
import atexit
from bitcointx.wallet import CCoinKey, XOnlyPubKey, tap_tweak_pubkey
import jmbitcoin as btc
from jmbase import bintohex, hexbin, get_log, EXIT_FAILURE
from jmbase import (bintohex, async_hexbin, get_log, EXIT_FAILURE,
twisted_sys_exit)
from jmclient.wallet import TaprootWallet, FrostWallet
from jmclient.wallet_service import WalletService
from jmclient.configure import jm_single
from jmclient.support import calc_cj_fee
@ -29,7 +33,7 @@ class Maker(object):
self.sync_wait_loop.start(2.0, now=False)
self.aborted = False
def try_to_create_my_orders(self):
async def try_to_create_my_orders(self):
"""Because wallet syncing is not synchronous(!),
we cannot calculate our offers until we know the wallet
contents, so poll until BlockchainInterface.wallet_synced
@ -38,14 +42,14 @@ class Maker(object):
"""
if not self.wallet_service.synced:
return
self.freeze_timelocked_utxos()
await self.freeze_timelocked_utxos()
try:
self.offerlist = self.create_my_orders()
except AssertionError:
jlog.error("Failed to create offers.")
self.aborted = True
return
self.fidelity_bond = self.get_fidelity_bond_template()
self.fidelity_bond = await self.get_fidelity_bond_template()
self.sync_wait_loop.stop()
if not self.offerlist:
jlog.error("Failed to create offers.")
@ -53,8 +57,9 @@ class Maker(object):
return
jlog.info('offerlist={}'.format(self.offerlist))
@hexbin
def on_auth_received(self, nick, offer, commitment, cr, amount, kphex):
@async_hexbin
async def on_auth_received(self, nick, offer, commitment,
cr, amount, kphex):
"""Receives data on proposed transaction offer from daemon, verifies
commitment, returns necessary data to send ioauth message (utxos etc)
"""
@ -106,7 +111,7 @@ class Maker(object):
# authorisation of taker passed
# Find utxos for the transaction now:
utxos, cj_addr, change_addr = self.oid_to_order(offer, amount)
utxos, cj_addr, change_addr = await self.oid_to_order(offer, amount)
if not utxos:
#could not find funds
return (False,)
@ -116,15 +121,38 @@ class Maker(object):
# Need to choose an input utxo pubkey to sign with
# Just choose the first utxo in utxos and retrieve key from wallet.
auth_address = next(iter(utxos.values()))['address']
auth_key = self.wallet_service.get_key_from_addr(auth_address)
auth_pub = btc.privkey_to_pubkey(auth_key)
# kphex was auto-converted by @hexbin but we actually need to sign the
# hex version to comply with pre-existing JM protocol:
btc_sig = btc.ecdsa_sign(bintohex(kphex), auth_key)
return (True, utxos, auth_pub, cj_addr, change_addr, btc_sig)
wallet = self.wallet_service.wallet
if isinstance(wallet, FrostWallet):
path = wallet.addr_to_path(auth_address)
md, address_type, index = wallet.get_details(path)
kphex_hash = hashlib.sha256(bintohex(kphex).encode()).digest()
sig, _, tweaked_pubkey = await wallet.ipc_client.frost_req(
md, address_type, index, kphex_hash)
sig = base64.b64encode(sig).decode('ascii')
if not sig:
return reject(str(tweaked_pubkey))
return (True, utxos, tweaked_pubkey[1:], cj_addr, change_addr, sig)
elif isinstance(wallet, TaprootWallet):
auth_key = self.wallet_service.get_key_from_addr(auth_address)
auth_pub = btc.privkey_to_pubkey(auth_key)
coin_key = CCoinKey.from_secret_bytes(auth_key[:32])
kphex_hash = hashlib.sha256(bintohex(kphex).encode()).digest()
sig = coin_key.sign_schnorr_tweaked(kphex_hash)
sig = base64.b64encode(sig).decode('ascii')
auth_pub_tweaked = tap_tweak_pubkey(XOnlyPubKey(auth_pub))
if auth_pub_tweaked is not None:
auth_pub_tweaked = bytes(auth_pub_tweaked[0])
return (True, utxos, auth_pub_tweaked, cj_addr, change_addr, sig)
else:
auth_key = self.wallet_service.get_key_from_addr(auth_address)
auth_pub = btc.privkey_to_pubkey(auth_key)
# kphex was auto-converted by @hexbin but we actually need to sign the
# hex version to comply with pre-existing JM protocol:
btc_sig = btc.ecdsa_sign(bintohex(kphex), auth_key)
return (True, utxos, auth_pub, cj_addr, change_addr, btc_sig)
@hexbin
def on_tx_received(self, nick, tx, offerinfo):
@async_hexbin
async def on_tx_received(self, nick, tx, offerinfo):
"""Called when the counterparty has sent an unsigned
transaction. Sigs are created and returned if and only
if the transaction passes verification checks (see
@ -158,7 +186,7 @@ class Maker(object):
amount = utxos[utxo]['value']
our_inputs[index] = (script, amount)
success, msg = self.wallet_service.sign_tx(tx, our_inputs)
success, msg = await self.wallet_service.sign_tx(tx, our_inputs)
assert success, msg
for index in our_inputs:
# The second case here is kept for backwards compatibility.
@ -171,9 +199,12 @@ class Maker(object):
elif self.wallet_service.get_txtype() == 'p2wpkh':
sig, pub = [a for a in iter(tx.wit.vtxinwit[index].scriptWitness)]
sigmsg = btc.CScript([sig]) + btc.CScript(pub)
elif self.wallet_service.get_txtype() == 'p2tr':
sig = [a for a in iter(tx.wit.vtxinwit[index].scriptWitness)]
sigmsg = btc.CScript(sig)
else:
jlog.error("Taker has unknown wallet type")
sys.exit(EXIT_FAILURE)
twisted_sys_exit(EXIT_FAILURE)
sigs.append(base64.b64encode(sigmsg).decode('ascii'))
return (True, sigs)
@ -264,7 +295,7 @@ class Maker(object):
self.offerlist.remove(oldorder_s[0])
self.offerlist += to_announce
def freeze_timelocked_utxos(self):
async def freeze_timelocked_utxos(self):
"""
Freeze all wallet's timelocked UTXOs. These cannot be spent in a
coinjoin because of protocol limitations.
@ -273,7 +304,7 @@ class Maker(object):
return
frozen_utxos = []
md_utxos = self.wallet_service.get_utxos_by_mixdepth()
md_utxos = await self.wallet_service.get_utxos_by_mixdepth()
for tx, details \
in md_utxos[self.wallet_service.FIDELITY_BOND_MIXDEPTH].items():
if self.wallet_service.is_timelocked_path(details['path']):
@ -298,7 +329,7 @@ class Maker(object):
"""
@abc.abstractmethod
def oid_to_order(self, cjorder, amount):
async def oid_to_order(self, cjorder, amount):
"""Must convert an order with an offer/order id
into a set of utxos to fill the order.
Also provides the output addresses for the Taker.
@ -316,7 +347,7 @@ class Maker(object):
a transaction into a block (e.g. announce orders)
"""
def get_fidelity_bond_template(self):
async def get_fidelity_bond_template(self):
"""
Generates information about a fidelity bond which will be announced
By default returns no fidelity bond

18
src/jmclient/output.py

@ -32,11 +32,11 @@ Are you sure you want to continue?"""
sweep_custom_change_warning = \
"Custom change cannot be set while doing a sweep (zero amount)."
def fmt_utxos(utxos, wallet_service, prefix=''):
async def fmt_utxos(utxos, wallet_service, prefix=''):
output = []
for u in utxos:
utxo_str = '{}{} - {}'.format(
prefix, fmt_utxo(u), fmt_tx_data(utxos[u], wallet_service))
prefix, fmt_utxo(u), await fmt_tx_data(utxos[u], wallet_service))
output.append(utxo_str)
return '\n'.join(output)
@ -45,14 +45,15 @@ def fmt_utxo(utxo):
assert success
return utxostr
def fmt_tx_data(tx_data, wallet_service):
async def fmt_tx_data(tx_data, wallet_service):
return 'path: {}, address: {} , value: {}'.format(
wallet_service.get_path_repr(wallet_service.script_to_path(tx_data['script'])),
wallet_service.script_to_addr(tx_data['script']), tx_data['value'])
await wallet_service.script_to_addr(tx_data['script']), tx_data['value'])
def generate_podle_error_string(priv_utxo_pairs, to, ts, wallet_service, cjamount,
taker_utxo_age, taker_utxo_amtpercent):
async def generate_podle_error_string(priv_utxo_pairs, to, ts, wallet_service,
cjamount, taker_utxo_age,
taker_utxo_amtpercent):
"""Gives detailed error information on why commitment sourcing failed.
"""
errmsg = ""
@ -93,9 +94,10 @@ def generate_podle_error_string(priv_utxo_pairs, to, ts, wallet_service, cjamoun
"with 'python add-utxo.py --help'\n\n")
errmsg += ("***\nFor reference, here are the utxos in your wallet:\n")
for md, utxos in wallet_service.get_utxos_by_mixdepth().items():
_utxos = await wallet_service.get_utxos_by_mixdepth()
for md, utxos in _utxos.items():
if not utxos:
continue
errmsg += ("\nmixdepth {}:\n{}".format(
md, fmt_utxos(utxos, wallet_service, prefix=' ')))
md, await fmt_utxos(utxos, wallet_service, prefix=' ')))
return (errmsgheader, errmsg)

132
src/jmclient/payjoin.py

@ -1,3 +1,4 @@
import asyncio
from twisted.internet import reactor
try:
from twisted.internet.ssl import ClientContextFactory
@ -371,7 +372,7 @@ class JMPayjoinManager(object):
else:
self.pj_state = self.JM_PJ_PAYJOIN_BROADCAST_FAILED
def select_receiver_utxos(self):
async def select_receiver_utxos(self):
# Receiver chooses own inputs:
# For earlier ideas about more complex algorithms, see the gist comment here:
# https://gist.github.com/AdamISZ/4551b947789d3216bacfcb7af25e029e#gistcomment-2799709
@ -389,7 +390,7 @@ class JMPayjoinManager(object):
self.user_info_callback("Choosing one coin at random")
try:
my_utxos = self.wallet_service.select_utxos(
my_utxos = await self.wallet_service.select_utxos(
self.mixdepth, jm_single().DUST_THRESHOLD,
select_fn=select_one_utxo, minconfs=1)
except Exception as e:
@ -473,7 +474,7 @@ def get_max_additional_fee_contribution(manager):
"contribution of: " + str(max_additional_fee_contribution))
return max_additional_fee_contribution
def make_payment_psbt(manager, accept_callback=None, info_callback=None):
async def make_payment_psbt(manager, accept_callback=None, info_callback=None):
""" Creates a valid payment transaction and PSBT for it,
and adds it to the JMPayjoinManager instance passed as argument.
Wallet should already be synced before calling here.
@ -482,12 +483,12 @@ def make_payment_psbt(manager, accept_callback=None, info_callback=None):
# we can create a standard payment, but have it returned as a PSBT.
assert isinstance(manager, JMPayjoinManager)
assert manager.wallet_service.synced
payment_psbt = direct_send(manager.wallet_service,
manager.mixdepth,
[(str(manager.destination), manager.amount)],
accept_callback=accept_callback,
info_callback=info_callback,
with_final_psbt=True)
payment_psbt = await direct_send(
manager.wallet_service, manager.mixdepth,
[(str(manager.destination), manager.amount)],
accept_callback=accept_callback,
info_callback=info_callback,
with_final_psbt=True)
if not payment_psbt:
return (False, "could not create non-payjoin payment")
@ -527,7 +528,7 @@ def make_payjoin_request_params(manager):
return params
def send_payjoin(manager, accept_callback=None,
async def send_payjoin(manager, accept_callback=None,
info_callback=None, return_deferred=False):
""" Given a JMPayjoinManager object `manager`, initialised with the
payment request data from the server, use its wallet_service to construct
@ -542,7 +543,8 @@ def send_payjoin(manager, accept_callback=None,
asynchronously) - the `manager` object can be inspected for more detail.
(False, errormsg) in case of failure.
"""
success, errmsg = make_payment_psbt(manager, accept_callback, info_callback)
success, errmsg = await make_payment_psbt(
manager, accept_callback, info_callback)
if not success:
return (False, errmsg)
@ -562,7 +564,7 @@ def send_payjoin(manager, accept_callback=None,
reactor.connectTCP(h, p, factory)
return (True, None)
def fallback_nonpayjoin_broadcast(err, manager):
async def fallback_nonpayjoin_broadcast(err, manager):
""" Sends the non-coinjoin payment onto the network,
assuming that the payjoin failed. The reason for failure is
`err` and will usually be communicated by the server, and must
@ -574,15 +576,18 @@ def fallback_nonpayjoin_broadcast(err, manager):
def quit():
if manager.mode == "command-line" and reactor.running:
process_shutdown()
log.warn("Payjoin did not succeed, falling back to non-payjoin payment.")
log.warn("Error message was: " + err.decode("utf-8"))
log.warning("Payjoin did not succeed, falling back to non-payjoin"
" payment.")
log.warning("Error message was: " + err.decode("utf-8"))
original_tx = manager.initial_psbt.extract_transaction()
if not jm_single().bc_interface.pushtx(original_tx.serialize()):
errormsg = ("Unable to broadcast original payment. Check your wallet\n"
"to see whether original payment was made.")
log.error(errormsg)
# ensure any GUI as well as command line sees the message:
manager.user_info_callback(errormsg)
cb_res = manager.user_info_callback(errormsg)
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
quit()
return
log.info("Payment made without coinjoin. Transaction: ")
@ -592,23 +597,24 @@ def fallback_nonpayjoin_broadcast(err, manager):
manager.timeout_fallback_dc.cancel()
quit()
def process_error_from_server(errormsg, errorcode, manager):
async def process_error_from_server(errormsg, errorcode, manager):
assert isinstance(manager, JMPayjoinManager)
# payjoin attempt has failed, we revert to standard payment.
assert int(errorcode) != 200
log.warn("Receiver returned error code: {}, message: {}".format(
errorcode, errormsg))
fallback_nonpayjoin_broadcast(errormsg.encode("utf-8"), manager)
log.warning("Receiver returned error code: {}, message:"
" {}".format(errorcode, errormsg))
await fallback_nonpayjoin_broadcast(errormsg.encode("utf-8"), manager)
return
def process_payjoin_proposal_from_server(response_body, manager):
async def process_payjoin_proposal_from_server(response_body, manager):
assert isinstance(manager, JMPayjoinManager)
try:
payjoin_proposal_psbt = \
btc.PartiallySignedTransaction.from_base64(response_body)
except Exception as e:
log.error("Payjoin tx from server could not be parsed: " + repr(e))
fallback_nonpayjoin_broadcast(b"Server sent invalid psbt", manager)
await fallback_nonpayjoin_broadcast(
b"Server sent invalid psbt", manager)
return
log.debug("Receiver sent us this PSBT: ")
log.debug(manager.wallet_service.human_readable_psbt(payjoin_proposal_psbt))
@ -621,11 +627,12 @@ def process_payjoin_proposal_from_server(response_body, manager):
payjoin_proposal_psbt.set_utxo(
manager.initial_psbt.inputs[j].utxo, i,
force_witness_utxo=True)
signresultandpsbt, err = manager.wallet_service.sign_psbt(
signresultandpsbt, err = await manager.wallet_service.sign_psbt(
payjoin_proposal_psbt.serialize(), with_sign_result=True)
if err:
log.error("Failed to sign PSBT from the receiver, error: " + err)
fallback_nonpayjoin_broadcast(manager, err=b"Failed to sign receiver PSBT")
await fallback_nonpayjoin_broadcast(
manager, err=b"Failed to sign receiver PSBT")
return
signresult, sender_signed_psbt = signresultandpsbt
@ -633,7 +640,8 @@ def process_payjoin_proposal_from_server(response_body, manager):
success, msg = manager.set_payjoin_psbt(payjoin_proposal_psbt, sender_signed_psbt)
if not success:
log.error(msg)
fallback_nonpayjoin_broadcast(manager, err=b"Receiver PSBT checks failed.")
await fallback_nonpayjoin_broadcast(
manager, err=b"Receiver PSBT checks failed.")
return
# All checks have passed. We can use the already signed transaction in
# sender_signed_psbt.
@ -678,7 +686,7 @@ class PayjoinConverter(object):
self.info_callback = info_callback
super().__init__()
def request_to_psbt(self, payment_psbt_base64, sender_parameters):
async def request_to_psbt(self, payment_psbt_base64, sender_parameters):
""" Takes a payment psbt from a sender and their url parameters,
and returns a new payment PSBT proposal, assuming all conditions
are met.
@ -756,7 +764,7 @@ class PayjoinConverter(object):
fallback_nonpayjoin_broadcast,
b"timeout", self.manager)
receiver_utxos = self.manager.select_receiver_utxos()
receiver_utxos = await self.manager.select_receiver_utxos()
if not receiver_utxos:
return (False, "Could not select coins for payjoin",
"unavailable")
@ -888,13 +896,14 @@ class PayjoinConverter(object):
log.debug("We created this unsigned tx: ")
log.debug(btc.human_readable_transaction(unsigned_payjoin_tx))
r_payjoin_psbt = self.wallet_service.create_psbt_from_tx(unsigned_payjoin_tx,
spent_outs=spent_outs)
r_payjoin_psbt = await self.wallet_service.create_psbt_from_tx(
unsigned_payjoin_tx, spent_outs=spent_outs)
log.debug("Receiver created payjoin PSBT:\n{}".format(
self.wallet_service.human_readable_psbt(r_payjoin_psbt)))
signresultandpsbt, err = self.wallet_service.sign_psbt(r_payjoin_psbt.serialize(),
with_sign_result=True)
signresultandpsbt, err = \
await self.wallet_service.sign_psbt(
r_payjoin_psbt.serialize(), with_sign_result=True)
assert not err, err
signresult, receiver_signed_psbt = signresultandpsbt
assert signresult.num_inputs_final == len(receiver_utxos)
@ -918,14 +927,20 @@ class PayjoinConverter(object):
cb_type="unconfirmed")
return (True, receiver_signed_psbt.to_base64(), None)
def end_receipt(self, txd, txid):
async def end_receipt(self, txd, txid):
if self.manager.mode == "gui":
self.info_callback("Transaction seen on network, "
"view wallet tab for update.:FINAL")
cb_res = self.info_callback("Transaction seen on network, view "
"wallet tab for update.:FINAL")
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
else:
self.info_callback("Transaction seen on network: " + txid)
cb_res = self.info_callback("Transaction seen on network: " + txid)
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
# in some cases (GUI) a notification of HS end is needed:
self.shutdown_callback()
cb_res = self.shutdown_callback()
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
# informs the wallet service transaction monitor
# that the transaction has been processed:
return True
@ -965,13 +980,16 @@ class JMBIP78ReceiverManager(object):
self.shutdown_callback = shutdown_callback
self.receiving_address = None
self.mode = mode
self.get_receiving_address()
async def async_init(self, wallet_service, mixdepth, amount,
mode="command-line"):
await self.get_receiving_address()
self.manager = JMPayjoinManager(wallet_service, mixdepth,
self.receiving_address, amount,
mode=mode,
user_info_callback=self.info_callback)
def initiate(self):
async def initiate(self):
""" Called at reactor start to start up hidden service
and provide uri string to sender.
"""
@ -980,7 +998,7 @@ class JMBIP78ReceiverManager(object):
# HTTP request simply doesn't arrive. Note also that the
# "params" argument is None as this is only learnt from request.
factory = BIP78ClientProtocolFactory(self, None,
self.receive_proposal_from_sender, None,
await self.receive_proposal_from_sender, None,
mode="receiver")
h = jm_single().config.get("DAEMON", "daemon_host")
p = jm_single().config.getint("DAEMON", "daemon_port")-2000
@ -992,27 +1010,27 @@ class JMBIP78ReceiverManager(object):
def default_info_callback(self, msg):
jmprint(msg)
def get_receiving_address(self):
async def get_receiving_address(self):
# the receiving address is sourced from the 'next' mixdepth
# to avoid clustering of input and output:
next_mixdepth = (self.mixdepth + 1) % (
self.wallet_service.wallet.mixdepth + 1)
self.receiving_address = btc.CCoinAddress(
self.wallet_service.get_internal_addr(next_mixdepth))
await self.wallet_service.get_internal_addr(next_mixdepth))
def receive_proposal_from_sender(self, body, params):
async def receive_proposal_from_sender(self, body, params):
""" Accepts the contents of the HTTP request from the sender
and returns a payjoin proposal, or an error.
"""
self.pj_converter = PayjoinConverter(self.manager,
self.shutdown, self.info_callback)
success, a, b = self.pj_converter.request_to_psbt(body, params)
success, a, b = await self.pj_converter.request_to_psbt(body, params)
if not success:
return (False, a, b)
else:
return (True, a)
def bip21_uri_from_onion_hostname(self, host):
async def bip21_uri_from_onion_hostname(self, host):
""" Encoding the BIP21 URI according to BIP78 specifications,
and specifically only supporting a hidden service endpoint.
Note: we hardcode http; no support for TLS over HS.
@ -1027,15 +1045,21 @@ class JMBIP78ReceiverManager(object):
{"amount": bip78_btc_amount,
"pj": full_pj_string.encode("utf-8")},
safe=":/")
self.info_callback("Your hidden service is available. Please\n"
"now pass this URI string to the sender to\n"
"effect the payjoin payment:")
self.uri_created_callback(bip21_uri)
cb_res = self.info_callback("Your hidden service is available. "
"Please\npass this URI string to the "
"sender to\neffect the payjoin payment:")
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
cb_res = self.uri_created_callback(bip21_uri)
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
if self.mode == "command-line":
self.info_callback("Keep this process running until the payment "
"is received.")
cb_res = self.info_callback("Keep this process running until the "
"payment is received.")
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
def shutdown(self):
async def shutdown(self):
""" Triggered when processing has completed successfully
or failed, receiver side.
"""
@ -1047,6 +1071,10 @@ class JMBIP78ReceiverManager(object):
tfdc = self.manager.timeout_fallback_dc
if tfdc and tfdc.active():
tfdc.cancel()
self.info_callback("Hidden service shutdown complete")
cb_res = self.info_callback("Hidden service shutdown complete")
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
if self.shutdown_callback:
self.shutdown_callback()
cb_res = self.shutdown_callback()
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res

5
src/jmclient/podle.py

@ -1,7 +1,6 @@
#Proof Of Discrete Logarithm Equivalence
#For algorithm steps, see https://gist.github.com/AdamISZ/9cbba5e9408d23813ca8
import os
import sys
import hashlib
import json
import struct
@ -9,7 +8,7 @@ from pprint import pformat
from jmbase import jmprint
from jmbitcoin import multiply, add_pubkeys, getG, podle_PublicKey,\
podle_PrivateKey, N, podle_PublicKey_class
from jmbase import (EXIT_FAILURE, utxostr_to_utxo,
from jmbase import (EXIT_FAILURE, utxostr_to_utxo, twisted_sys_exit,
utxo_to_utxostr, hextobin, bintohex)
PODLE_COMMIT_FILE = None
@ -345,7 +344,7 @@ def read_from_podle_file():
#Exit conditions cannot be included in tests.
jmprint("the file: " + PODLE_COMMIT_FILE + " is not valid json.",
"error")
sys.exit(EXIT_FAILURE)
twisted_sys_exit(EXIT_FAILURE)
if 'used' not in c.keys() or 'external' not in c.keys():
raise PoDLEError("Incorrectly formatted file: " + PODLE_COMMIT_FILE)

35
src/jmclient/scripts_support.py

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
import asyncio
import sys
from functools import wraps
from jmbase import stop_reactor
def wrap_main(func):
@wraps(func)
async def func_wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except SystemExit as e:
return e.args[0] if e.args else None
finally:
for task in asyncio.all_tasks():
task.cancel()
stop_reactor()
return func_wrapper
def finalize_main_task(main_task):
if main_task.done():
try:
exit_status = main_task.result()
if exit_status:
sys.exit(exit_status)
except asyncio.CancelledError:
pass
except Exception:
raise

10
src/jmclient/snicker_receiver.py

@ -109,7 +109,7 @@ class SNICKERReceiver(object):
jlog.info("created proposals source file.")
def default_acceptance_callback(self, our_ins, their_ins,
async def default_acceptance_callback(self, our_ins, their_ins,
our_outs, their_outs):
""" Accepts lists of inputs as CTXIns,
a single output (belonging to us) as a CTxOut,
@ -124,7 +124,7 @@ class SNICKERReceiver(object):
# ours.
# we use get_all* because for these purposes mixdepth
# is irrelevant.
utxos = self.wallet_service.get_all_utxos()
utxos = await self.wallet_service.get_all_utxos()
our_in_amts = []
our_out_amts = []
for i in our_ins:
@ -149,7 +149,7 @@ class SNICKERReceiver(object):
self.successful_txs.append(tx)
jlog.info(btc.human_readable_transaction(tx))
def process_proposals(self, proposals):
async def process_proposals(self, proposals):
""" This is the "meat" of the SNICKERReceiver service.
It parses proposals and creates and broadcasts transactions
with the wallet, assuming all conditions are met.
@ -199,7 +199,7 @@ class SNICKERReceiver(object):
jlog.debug("Key not recognized as part of our "
"wallet, ignoring.")
continue
result = self.wallet_service.parse_proposal_to_signed_tx(
result = await self.wallet_service.parse_proposal_to_signed_tx(
addr, p, self.acceptance_callback)
if result[0] is not None:
tx, tweak, out_spk = result
@ -234,7 +234,7 @@ class SNICKERReceiver(object):
# the coinjoin transaction to the network, which is advisably
# conservative (never possible to have broadcast a tx without
# having already stored the output's key).
success, msg = self.wallet_service.check_tweak_matches_and_import(
success, msg = await self.wallet_service.check_tweak_matches_and_import(
addr, tweak, tweaked_key, source_mixdepth)
if not success:
jlog.error(msg)

183
src/jmclient/storage.py

@ -365,3 +365,186 @@ class VolatileStorage(Storage):
def get_location(self):
return None
class DKGStorage(Storage):
MAGIC_UNENC = b'JMDKGDAT'
MAGIC_ENC = b'JMDKGENC'
MAGIC_DETECT_ENC = b'JMDKGDAT'
@staticmethod
def dkg_path(path):
return f'{path}.dkg'
class DKGRecoveryStorage(object):
MAGIC_UNENC = b'JMDKGREC'
def __init__(self, path, create=False, read_only=False):
self.path = path
self._lock_file = None
self._data_checksum = None
self.data = None
self.read_only = read_only
self.newly_created = False
if not os.path.isfile(path):
if create and not read_only:
self._create_new()
self._save_file()
self.newly_created = True
else:
raise StorageError(f'DKG Recovery File {self.path} not found.')
elif create:
raise StorageError(f'DKG Recovery File {self.path} '
f'already exists.')
else:
self._load_file()
assert self.data is not None
assert self._data_checksum is not None
self._create_lock()
@staticmethod
def dkg_recovery_path(path):
return f'{path}.dkg_recovery'
@staticmethod
def _serialize(data):
return bencode_utf8(data)
@staticmethod
def _deserialize(data):
return bdecode(data)
@staticmethod
def _get_lock_filename(path: str) -> str:
(path_head, path_tail) = os.path.split(path)
return os.path.join(path_head, '.' + path_tail + '.lock')
@classmethod
def verify_lock(cls, path: str):
locked_by_pid = cls._get_locking_pid(path)
if locked_by_pid >= 0:
raise RetryableStorageError(
"File is currently in use (locked by pid {}). "
"If this is a leftover from a crashed instance "
"you need to remove the lock file `{}` manually.".
format(locked_by_pid, cls._get_lock_filename(path))
)
@classmethod
def _get_locking_pid(cls, path: str) -> int:
"""Return locking PID, -1 if no lockfile if found, 0 if PID cannot be read."""
try:
with open(cls._get_lock_filename(path), 'r') as f:
return int(f.read())
except FileNotFoundError:
return -1
except ValueError:
return 0
@classmethod
def is_storage_file(cls, path):
return cls._get_file_magic(path) == cls.MAGIC_UNENC
@classmethod
def _get_file_magic(cls, path):
with open(path, 'rb') as fh:
return fh.read(len(cls.MAGIC_UNENC))
def _create_new(self):
self.data = {}
def _save_file(self):
assert self.read_only == False
data = self._serialize(self.data)
magic = self.MAGIC_UNENC
self._write_file(magic + data)
self._update_data_hash()
def _write_file(self, data):
assert self.read_only is False
if not os.path.exists(self.path):
# newly created storage
with open(self.path, 'wb') as fh:
fh.write(data)
return
# using a tmpfile ensures the write is atomic
tmpfile = '{}.tmp'.format(self.path)
with open(tmpfile, 'wb') as fh:
shutil.copystat(self.path, tmpfile)
fh.write(data)
#FIXME: behaviour with symlinks might be weird
shutil.move(tmpfile, self.path)
def _update_data_hash(self):
self._data_checksum = self._get_data_checksum()
def _get_data_checksum(self):
if self.data is None: #pragma: no cover
return None
return sha256(self._serialize(self.data)).digest()
def _create_lock(self):
if not self.read_only:
self._lock_file = self._get_lock_filename(self.path)
try:
with open(self._lock_file, 'x') as f:
f.write(str(os.getpid()))
except FileExistsError:
self._lock_file = None
self.verify_lock(self.path)
atexit.register(self.close)
def was_changed(self):
return self._data_checksum != self._get_data_checksum()
def save(self):
if self.read_only:
raise StorageError("Read-only recovery storage cannot be saved.")
self._save_file()
def _load_file(self):
data = self._read_file()
assert len(self.MAGIC_UNENC) == 8
magic = data[:8]
if magic != self.MAGIC_UNENC:
raise StorageError("File does not appear to be "
"a DKG Recovery File.")
data = data[8:]
self.data = self._deserialize(data)
self._update_data_hash()
def _read_file(self):
# this method mainly exists for easier mocking
with open(self.path, 'rb') as fh:
return fh.read()
def get_location(self):
return self.path
def _remove_lock(self):
if self._lock_file is not None:
try:
os.remove(self._lock_file)
except FileNotFoundError:
pass
def close(self):
if not self.read_only and self.was_changed():
self._save_file()
self._remove_lock()
self.read_only = True
def __del__(self):
self.close()

16
src/jmclient/support.py

@ -167,9 +167,9 @@ def select_one_utxo(unspent, value):
def calc_cj_fee(ordertype, cjfee, cj_amount):
if ordertype in ['sw0absoffer', 'swabsoffer', 'absoffer']:
if ordertype in ['trabsoffer', 'sw0absoffer', 'swabsoffer', 'absoffer']:
real_cjfee = int(cjfee)
elif ordertype in ['sw0reloffer', 'swreloffer', 'reloffer']:
elif ordertype in ['trreloffer', 'sw0reloffer', 'swreloffer', 'reloffer']:
real_cjfee = int((Decimal(cjfee) * Decimal(cj_amount)).quantize(Decimal(
1)))
else:
@ -269,9 +269,9 @@ def choose_orders(offers, cj_amount, n, chooseOrdersBy, ignored_makers=None,
counterparties = set(o['counterparty'] for o, f in orders_fees)
if n > len(counterparties):
log.warn(('ERROR not enough liquidity in the orderbook n=%d '
'suitable-counterparties=%d amount=%d totalorders=%d') %
(n, len(counterparties), cj_amount, len(orders_fees)))
log.warning(('ERROR not enough liquidity in the orderbook n=%d '
'suitable-counterparties=%d amount=%d totalorders=%d') %
(n, len(counterparties), cj_amount, len(orders_fees)))
# TODO handle not enough liquidity better, maybe an Exception
return None, 0
"""
@ -338,9 +338,11 @@ def choose_sweep_orders(offers,
sumtxfee_contribution = 0
for order in ordercombo:
sumtxfee_contribution += order['txfee']
if order['ordertype'] in ['sw0absoffer', 'swabsoffer', 'absoffer']:
if order['ordertype'] in ['trabsoffer', 'sw0absoffer',
'swabsoffer', 'absoffer']:
sumabsfee += int(order['cjfee'])
elif order['ordertype'] in ['sw0reloffer', 'swreloffer', 'reloffer']:
elif order['ordertype'] in ['trreloffer', 'sw0reloffer',
'swreloffer', 'reloffer']:
sumrelfee += Decimal(order['cjfee'])
#this is unreachable since calc_cj_fee must already have been called
else: #pragma: no cover

271
src/jmclient/taker.py

@ -1,9 +1,13 @@
#! /usr/bin/env python
import asyncio
import base64
import hashlib
import pprint
import random
from typing import Any, NamedTuple, Optional
from bitcointx.core.key import XOnlyPubKey
from twisted.internet import reactor, task
import jmbitcoin as btc
@ -12,7 +16,8 @@ from jmbase import get_log, bintohex, hexbin
from jmclient.support import (calc_cj_fee, fidelity_bond_weighted_order_choose, choose_orders,
choose_sweep_orders)
from jmclient.wallet import (estimate_tx_fee, compute_tx_locktime,
FidelityBondMixin, UnknownAddressForLabel)
FidelityBondMixin, UnknownAddressForLabel,
TaprootWallet, FrostWallet)
from jmclient.podle import generate_podle, get_podle_commitments
from jmclient.wallet_service import WalletService
from jmclient.fidelity_bond import FidelityBondProof
@ -170,19 +175,27 @@ class Taker(object):
return
self.honest_only = truefalse
def initialize(self, orderbook, fidelity_bonds_info):
async def initialize(self, orderbook, fidelity_bonds_info):
"""Once the daemon is active and has returned the current orderbook,
select offers, re-initialize variables and prepare a commitment,
then send it to the protocol to fill offers.
"""
if self.aborted:
return (False,)
self.taker_info_callback("INFO", "Received offers from joinmarket pit")
info_cb_res = self.taker_info_callback(
"INFO", "Received offers from joinmarket pit")
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
#choose the next item in the schedule
self.schedule_index += 1
if self.schedule_index == len(self.schedule):
self.taker_info_callback("INFO", "Finished all scheduled transactions")
self.on_finished_callback(True)
info_cb_res = self.taker_info_callback(
"INFO", "Finished all scheduled transactions")
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
finished_cb_res = self.on_finished_callback(True)
if asyncio.iscoroutine(finished_cb_res):
finished_cb_res = await finished_cb_res
return (False,)
else:
#read the settings from the schedule entry
@ -223,7 +236,8 @@ class Taker(object):
self.wallet_service.mixdepth + 1)
jlog.info("Choosing a destination from mixdepth: " + str(
next_mixdepth))
self.my_cj_addr = self.wallet_service.get_internal_addr(next_mixdepth)
self.my_cj_addr = await self.wallet_service.get_internal_addr(
next_mixdepth)
jlog.info("Chose destination address: " + self.my_cj_addr)
self.outputs = []
self.cjfee_total = 0
@ -237,14 +251,17 @@ class Taker(object):
offer["fidelity_bond_value"] = fidelity_bond_values.get(offer["counterparty"], 0)
sweep = True if self.cjamount == 0 else False
if not self.filter_orderbook(orderbook, sweep):
if not await self.filter_orderbook(orderbook, sweep):
return (False,)
#choose coins to spend
self.taker_info_callback("INFO", "Preparing bitcoin data..")
if not self.prepare_my_bitcoin_data():
info_cb_res = self.taker_info_callback(
"INFO", "Preparing bitcoin data..")
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
if not await self.prepare_my_bitcoin_data():
return (False,)
#Prepare a commitment
commitment, revelation, errmsg = self.make_commitment()
commitment, revelation, errmsg = await self.make_commitment()
if not commitment:
utxo_pairs, to, ts = revelation
if len(to) == 0:
@ -252,20 +269,26 @@ class Taker(object):
#until they get old enough; otherwise, we have to abort
#(TODO, it's possible for user to dynamically add more coins,
#consider if this option means we should stay alive).
self.taker_info_callback("ABORT", errmsg)
info_cb_res = self.taker_info_callback("ABORT", errmsg)
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
return ("commitment-failure",)
else:
self.taker_info_callback("INFO", errmsg)
info_cb_res = self.taker_info_callback("INFO", errmsg)
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
return (False,)
else:
self.taker_info_callback("INFO", errmsg)
info_cb_res = self.taker_info_callback("INFO", errmsg)
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
#Initialization has been successful. We must set the nonrespondants
#now to keep track of what changed when we receive the utxo data
self.nonrespondants = list(self.orderbook.keys())
return (True, self.cjamount, commitment, revelation, self.orderbook)
def filter_orderbook(self, orderbook, sweep=False):
async def filter_orderbook(self, orderbook, sweep=False):
#If honesty filter is set, we immediately filter to only the prescribed
#honest makers before continuing. In this case, the number of
#counterparties should already match, and this has to be set by the
@ -284,6 +307,8 @@ class Taker(object):
allowed_types = ["swreloffer", "swabsoffer"]
elif self.wallet_service.get_txtype() == "p2wpkh":
allowed_types = ["sw0reloffer", "sw0absoffer"]
elif self.wallet_service.get_txtype() == "p2tr":
allowed_types = ["trreloffer", "trabsoffer"]
else:
jlog.error("Unrecognized wallet type, taker cannot continue.")
return False
@ -300,6 +325,8 @@ class Taker(object):
accepted = self.filter_orders_callback([self.orderbook,
self.total_cj_fee],
self.cjamount)
if asyncio.iscoroutine(accepted):
accepted = await accepted
if accepted == "retry":
#Special condition if Taker is "determined to continue"
#(such as tumbler); even though these offers are rejected,
@ -310,7 +337,7 @@ class Taker(object):
return False
return True
def prepare_my_bitcoin_data(self):
async def prepare_my_bitcoin_data(self):
"""Get a coinjoin address and a change address; prepare inputs
appropriate for this transaction"""
if not self.my_cj_addr:
@ -321,7 +348,9 @@ class Taker(object):
self.my_change_addr = self.custom_change_address
else:
try:
self.my_change_addr = self.wallet_service.get_internal_addr(self.mixdepth)
self.my_change_addr = \
await self.wallet_service.get_internal_addr(
self.mixdepth)
if self.change_label:
try:
self.wallet_service.set_address_label(
@ -330,7 +359,10 @@ class Taker(object):
# ignore, will happen with custom change not part of a wallet
pass
except:
self.taker_info_callback("ABORT", "Failed to get a change address")
info_cb_res = self.taker_info_callback(
"ABORT", "Failed to get a change address")
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
return False
#adjust the required amount upwards to anticipate an increase in
#transaction fees after re-estimation; this is sufficiently conservative
@ -344,15 +376,19 @@ class Taker(object):
total_amount = self.cjamount + self.total_cj_fee + self.total_txfee
jlog.info('total estimated amount spent = ' + btc.amount_to_str(total_amount))
try:
self.input_utxos = self.wallet_service.select_utxos(self.mixdepth, total_amount,
minconfs=1)
self.input_utxos = await self.wallet_service.select_utxos(
self.mixdepth, total_amount, minconfs=1)
except Exception as e:
self.taker_info_callback("ABORT",
"Unable to select sufficient coins: " + repr(e))
info_cb_res = self.taker_info_callback(
"ABORT", "Unable to select sufficient coins: " + repr(e))
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
return False
else:
#sweep
self.input_utxos = self.wallet_service.get_utxos_by_mixdepth()[self.mixdepth]
ws = self.wallet_service
_utxos = await ws.get_utxos_by_mixdepth()
self.input_utxos = _utxos[self.mixdepth]
self.my_change_addr = None
#do our best to estimate the fee based on the number of
#our own utxos; this estimate may be significantly higher
@ -374,6 +410,8 @@ class Taker(object):
allowed_types = ["swreloffer", "swabsoffer"]
elif self.wallet_service.get_txtype() == "p2wpkh":
allowed_types = ["sw0reloffer", "sw0absoffer"]
elif self.wallet_service.get_txtype() == "p2tr":
allowed_types = ["trreloffer", "trabsoffer"]
else:
jlog.error("Unrecognized wallet type, taker cannot continue.")
return False
@ -383,20 +421,25 @@ class Taker(object):
self.ignored_makers, allowed_types=allowed_types,
max_cj_fee=self.max_cj_fee)
if not self.orderbook:
self.taker_info_callback("ABORT",
"Could not find orders to complete transaction")
info_cb_res = self.taker_info_callback(
"ABORT", "Could not find orders to complete transaction")
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
return False
if self.filter_orders_callback:
if not self.filter_orders_callback((self.orderbook,
self.total_cj_fee),
self.cjamount):
accepted = self.filter_orders_callback((self.orderbook,
self.total_cj_fee),
self.cjamount)
if asyncio.iscoroutine(accepted):
accepted = await accepted
if not accepted:
return False
self.utxos = {None: list(self.input_utxos.keys())}
return True
@hexbin
def receive_utxos(self, ioauth_data):
async def receive_utxos(self, ioauth_data):
"""Triggered when the daemon returns utxo data from
makers who responded; this is the completion of phase 1
of the protocol
@ -428,8 +471,8 @@ class Taker(object):
try:
self.nonrespondants.remove(maker_inputs.nick)
except Exception as e:
jlog.warn(
"Failure to remove counterparty from nonrespondants list:"
jlog.warning(
f"Failure to remove counterparty from nonrespondants list:"
f" {maker_inputs.nick}), error message: {repr(e)})")
#Apply business logic of how many counterparties are enough; note that
@ -437,11 +480,17 @@ class Taker(object):
#know for sure that the data meets all business-logic requirements.
if len(self.maker_utxo_data) < jm_single().config.getint(
"POLICY", "minimum_makers"):
self.taker_info_callback("INFO", "Not enough counterparties, aborting.")
info_cb_res = self.taker_info_callback(
"INFO", "Not enough counterparties, aborting.")
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
return (False,
"Not enough counterparties responded to fill, giving up")
self.taker_info_callback("INFO", "Got all parts, enough to build a tx")
info_cb_res = self.taker_info_callback(
"INFO", "Got all parts, enough to build a tx")
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
#The list self.nonrespondants is now reset and
#used to track return of signatures for phase 2
@ -505,10 +554,11 @@ class Taker(object):
sweep_delta = float(jm_single().config.get("POLICY",
"max_sweep_fee_change"))
if feeratio < 1 - sweep_delta or feeratio > 1 + sweep_delta:
jlog.warn("Transaction fee for sweep: {} too far from expected:"
" {}; check the setting 'max_sweep_fee_change'"
" in joinmarket.cfg. Aborting this attempt.".format(
new_total_fee, self.total_txfee))
jlog.warning("Transaction fee for sweep: {} too far from"
" expected: {}; check the setting"
" 'max_sweep_fee_change' in joinmarket.cfg."
" Aborting this ""attempt."
"".format(new_total_fee, self.total_txfee))
return (False, "Unacceptable feerate for sweep, giving up.")
else:
self.outputs.append({'address': self.my_change_addr,
@ -532,7 +582,10 @@ class Taker(object):
jlog.info('obtained tx\n' + btc.human_readable_transaction(
self.latest_tx))
self.taker_info_callback("INFO", "Built tx, sending to counterparties.")
info_cb_res = self.taker_info_callback(
"INFO", "Built tx, sending to counterparties.")
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
return (True, list(self.maker_utxo_data.keys()),
self.latest_tx.serialize())
@ -549,8 +602,8 @@ class Taker(object):
if not validate_address(cj_addr)[0]\
or not validate_address(change_addr)[0]:
jlog.warn("Counterparty provided invalid address: {}".format(
(cj_addr, change_addr)))
jlog.warning("Counterparty provided invalid address:"
" {}".format((cj_addr, change_addr)))
# Interpreted as malicious
self.add_ignored_makers([nick])
continue
@ -586,9 +639,14 @@ class Taker(object):
f"maker's ({nick}) proposed utxo is not confirmed, "
"rejecting."])
try:
if self.wallet_service.pubkey_has_script(
auth_pub, inp['script']):
break
if isinstance(self.wallet_service.wallet, TaprootWallet):
if self.wallet_service.output_pubkey_has_script(
auth_pub, inp['script']):
break
else:
if self.wallet_service.pubkey_has_script(
auth_pub, inp['script']):
break
except EngineError as e:
pass
else:
@ -622,17 +680,33 @@ class Taker(object):
with an ecdsa verification.
"""
try:
wallet = self.wallet_service.wallet
# maker pubkey as message is in hex format:
if not btc.ecdsa_verify(bintohex(maker_pk), btc_sig, auth_pub):
jlog.debug('signature didnt match pubkey and message')
return False
if isinstance(wallet, (TaprootWallet, FrostWallet)):
pubkey = XOnlyPubKey(auth_pub)
kphex_hash = hashlib.sha256(
bintohex(maker_pk).encode()).digest()
btc_sig = base64.b64decode(btc_sig)
if not pubkey.verify_schnorr(kphex_hash, btc_sig):
jlog.debug('schnorr signature didnt match '
'pubkey and message')
return False
else:
if not btc.ecdsa_verify(bintohex(maker_pk), btc_sig, auth_pub):
jlog.debug('signature didnt match pubkey and message')
return False
except Exception as e:
jlog.info("Failed ecdsa verify for maker pubkey: " + bintohex(maker_pk))
if isinstance(wallet, (TaprootWallet, FrostWallet)):
jlog.info("Failed schnorr verify for maker pubkey: " +
bintohex(maker_pk))
else:
jlog.info("Failed ecdsa verify for maker pubkey: " +
bintohex(maker_pk))
jlog.info("Exception was: " + repr(e))
return False
return True
def on_sig(self, nick, sigb64):
async def on_sig(self, nick, sigb64):
"""Processes transaction signatures from counterparties.
If all signatures received correctly, returns the result
of self.self_sign_and_push() (i.e. we complete the signing
@ -679,8 +753,11 @@ class Taker(object):
jlog.debug("Junk signature: " + str(sig_deserialized) + \
", not attempting to verify")
break
# The second case here is kept for backwards compatibility.
if len(sig_deserialized) == 2:
# The third case here is kept for backwards compatibility.
if len(sig_deserialized) == 1:
ver_sig = sig_deserialized[0]
ver_pub = None
elif len(sig_deserialized) == 2:
ver_sig, ver_pub = sig_deserialized
elif len(sig_deserialized) == 3:
ver_sig, ver_pub, _ = sig_deserialized
@ -689,10 +766,17 @@ class Taker(object):
break
scriptPubKey = btc.CScript(utxo_data[i]['script'])
is_witness_input = scriptPubKey.is_p2sh() or scriptPubKey.is_witness_v0_keyhash()
is_witness_input = (scriptPubKey.is_p2sh()
or scriptPubKey.is_witness_v0_keyhash()
or scriptPubKey.is_witness_v1_taproot())
ver_amt = utxo_data[i]['value'] if is_witness_input else None
witness = btc.CScriptWitness(
[ver_sig, ver_pub]) if is_witness_input else None
if is_witness_input:
if ver_pub:
witness = btc.CScriptWitness([ver_sig, ver_pub])
else:
witness = btc.CScriptWitness([ver_sig])
else:
witness = None
# don't attempt to parse `pub` as pubkey unless it's valid.
if scriptPubKey.is_p2sh():
@ -704,13 +788,20 @@ class Taker(object):
if scriptPubKey.is_witness_v0_keyhash():
scriptSig = btc.CScript(b'')
elif scriptPubKey.is_witness_v1_taproot():
scriptSig = btc.CScript(b'')
elif scriptPubKey.is_p2sh():
scriptSig = btc.CScript([s])
else:
scriptSig = btc.CScript([ver_sig, ver_pub])
spent_outputs = None
wallet = self.wallet_service.wallet
if isinstance(wallet, (TaprootWallet, FrostWallet)):
spent_outputs = wallet.get_spent_outputs(self.latest_tx)
sig_good = btc.verify_tx_input(self.latest_tx, u[0], scriptSig,
scriptPubKey, amount=ver_amt, witness=witness)
scriptPubKey, amount=ver_amt, witness=witness,
spent_outputs=spent_outputs)
if sig_good:
jlog.debug('found good sig at index=%d' % (u[0]))
@ -754,11 +845,14 @@ class Taker(object):
return False
assert not len(self.nonrespondants)
jlog.info('all makers have sent their signatures')
self.taker_info_callback("INFO", "Transaction is valid, signing..")
info_cb_res = self.taker_info_callback(
"INFO", "Transaction is valid, signing..")
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
jlog.debug("schedule item was: " + str(self.schedule[self.schedule_index]))
return self.self_sign_and_push()
return await self.self_sign_and_push()
def make_commitment(self):
async def make_commitment(self):
"""The Taker default commitment function, which uses PoDLE.
Alternative commitment types should use a different commit type byte.
This will allow future upgrades to provide different style commitments
@ -789,7 +883,7 @@ class Taker(object):
newresults.append(utxos[i])
return newresults, too_new, too_small
def priv_utxo_pairs_from_utxos(utxos, age, amt):
async def priv_utxo_pairs_from_utxos(utxos, age, amt):
#returns pairs list of (priv, utxo) for each valid utxo;
#also returns lists "too_new" and "too_small" for any
#utxos that did not satisfy the criteria for debugging.
@ -800,9 +894,10 @@ class Taker(object):
for k, v in new_utxos_dict.items():
# filter out any non-standard utxos:
path = self.wallet_service.script_to_path(v["script"])
if not self.wallet_service.is_standard_wallet_script(path):
ws = self.wallet_service
if not await ws.is_standard_wallet_script(path):
continue
addr = self.wallet_service.script_to_addr(v["script"])
addr = await self.wallet_service.script_to_addr(v["script"])
priv = self.wallet_service.get_key_from_addr(addr)
if priv: #can be null from create-unsigned
priv_utxo_pairs.append((priv, k))
@ -815,8 +910,8 @@ class Taker(object):
amt = int(self.cjamount *
jm_single().config.getint("POLICY",
"taker_utxo_amtpercent") / 100.0)
priv_utxo_pairs, to, ts = priv_utxo_pairs_from_utxos(self.input_utxos,
age, amt)
priv_utxo_pairs, to, ts = await priv_utxo_pairs_from_utxos(
self.input_utxos, age, amt)
#For podle data format see: podle.PoDLE.reveal()
#In first round try, don't use external commitments
@ -835,12 +930,15 @@ class Taker(object):
#in the transaction, about to be consumed, rather than use
#random utxos that will persist after. At this step we also
#allow use of external utxos in the json file.
mixdepth_utxos = self.wallet_service.get_utxos_by_mixdepth()[self.mixdepth]
ws = self.wallet_service
_utxos = await ws.get_utxos_by_mixdepth()
mixdepth_utxos = _utxos[self.mixdepth]
if len(self.input_utxos) == len(mixdepth_utxos):
# Already tried the whole mixdepth
podle_data = generate_podle([], tries, ext_valid)
else:
priv_utxo_pairs, to, ts = priv_utxo_pairs_from_utxos(mixdepth_utxos, age, amt)
priv_utxo_pairs, to, ts = await priv_utxo_pairs_from_utxos(
mixdepth_utxos, age, amt)
podle_data = generate_podle(priv_utxo_pairs, tries, ext_valid)
if podle_data:
jlog.debug("Generated PoDLE: " + repr(podle_data))
@ -848,7 +946,8 @@ class Taker(object):
podle_data.serialize_revelation(),
"Commitment sourced OK")
else:
errmsgheader, errmsg = generate_podle_error_string(priv_utxo_pairs,
errmsgheader, errmsg = await generate_podle_error_string(
priv_utxo_pairs,
to, ts, self.wallet_service, self.cjamount,
jm_single().config.get("POLICY", "taker_utxo_age"),
jm_single().config.get("POLICY", "taker_utxo_amtpercent"))
@ -868,7 +967,7 @@ class Taker(object):
#Note: donation code removed (possibly temporarily)
raise NotImplementedError
def self_sign(self):
async def self_sign(self):
# now sign it ourselves
our_inputs = {}
for index, ins in enumerate(self.latest_tx.vin):
@ -879,11 +978,12 @@ class Taker(object):
script = self.input_utxos[utxo]['script']
amount = self.input_utxos[utxo]['value']
our_inputs[index] = (script, amount)
success, msg = self.wallet_service.sign_tx(self.latest_tx, our_inputs)
success, msg = await self.wallet_service.sign_tx(
self.latest_tx, our_inputs)
if not success:
jlog.error("Failed to sign transaction: " + msg)
def handle_unbroadcast_transaction(self, txid, tx):
async def handle_unbroadcast_transaction(self, txid, tx):
""" The wallet service will handle dangling
callbacks for transactions but we want to reattempt
broadcast in case the cause of the problem is a
@ -900,9 +1000,12 @@ class Taker(object):
if jm_single().config.get('POLICY', 'tx_broadcast') == "not-self":
warnmsg = ("You have chosen not to broadcast from your own "
"node. The transaction is NOT broadcast.")
self.taker_info_callback("ABORT", warnmsg + "\nSee log for details.")
info_cb_res = self.taker_info_callback(
"ABORT", warnmsg + "\nSee log for details.")
if asyncio.iscoroutine(info_cb_res):
info_cb_res = await info_cb_res
# warning is arguably not correct but it will stand out more:
jlog.warn(warnmsg)
jlog.warning(warnmsg)
jlog.info(btc.human_readable_transaction(tx))
return
if not self.push_ourselves():
@ -912,7 +1015,7 @@ class Taker(object):
def push_ourselves(self):
return jm_single().bc_interface.pushtx(self.latest_tx.serialize())
def push(self):
async def push(self):
jlog.debug('\n' + bintohex(self.latest_tx.serialize()))
self.txid = bintohex(self.latest_tx.GetTxid()[::-1])
jlog.info('txid = ' + self.txid)
@ -933,7 +1036,8 @@ class Taker(object):
task.deferLater(reactor,
float(jm_single().config.getint(
"TIMEOUT", "unconfirm_timeout_sec")),
self.handle_unbroadcast_transaction, self.txid, self.latest_tx)
self.handle_unbroadcast_transaction,
self.txid, self.latest_tx)
tx_broadcast = jm_single().config.get('POLICY', 'tx_broadcast')
nick_to_use = None
@ -955,15 +1059,17 @@ class Taker(object):
"methods supported. Reverting to self-broadcast.")
pushed = self.push_ourselves()
if not pushed:
self.on_finished_callback(False, fromtx=True)
finished_cb_res = self.on_finished_callback(False, fromtx=True)
if asyncio.iscoroutine(finished_cb_res):
finished_cb_res = await finished_cb_res
else:
if nick_to_use:
return (nick_to_use, self.latest_tx.serialize())
#if push was not successful, return None
def self_sign_and_push(self):
self.self_sign()
return self.push()
async def self_sign_and_push(self):
await self.self_sign()
return await self.push()
def tx_match(self, txd):
# Takers process only in series, so this should not occur:
@ -973,12 +1079,14 @@ class Taker(object):
return False
return True
def unconfirm_callback(self, txd, txid):
async def unconfirm_callback(self, txd, txid):
if not self.tx_match(txd):
return False
jlog.info("Transaction seen on network, waiting for confirmation")
#To allow client to mark transaction as "done" (e.g. by persisting state)
self.on_finished_callback(True, fromtx="unconfirmed")
finished_cb_res= self.on_finished_callback(True, fromtx="unconfirmed")
if asyncio.iscoroutine(finished_cb_res):
finished_cb_res = await finished_cb_res
self.waiting_for_conf = True
confirm_timeout_sec = float(jm_single().config.get(
"TIMEOUT", "confirm_timeout_hours")) * 3600
@ -988,7 +1096,7 @@ class Taker(object):
"transaction with txid " + str(txid) + " not confirmed.")
return True
def confirm_callback(self, txd, txid, confirmations):
async def confirm_callback(self, txd, txid, confirmations):
if not self.tx_match(txd):
return False
self.waiting_for_conf = False
@ -1000,14 +1108,17 @@ class Taker(object):
jlog.debug("Confirmed callback in taker, confs: " + str(confirmations))
fromtx=False if self.schedule_index + 1 == len(self.schedule) else True
waittime = self.schedule[self.schedule_index][4]
self.on_finished_callback(True, fromtx=fromtx, waittime=waittime,
txdetails=(txd, txid))
finished_cb_res = self.on_finished_callback(
True, fromtx=fromtx, waittime=waittime, txdetails=(txd, txid))
if asyncio.iscoroutine(finished_cb_res):
finished_cb_res = await finished_cb_res
return True
def _is_our_input(self, tx_input):
utxo = (tx_input.prevout.hash[::-1], tx_input.prevout.n)
return utxo in self.input_utxos
def round_to_significant_figures(d, sf):
'''Rounding number d to sf significant figures in base 10'''
for p in range(-10, 15):

43
src/jmclient/taker_utils.py

@ -1,7 +1,7 @@
import asyncio
import logging
import pprint
import os
import sys
import time
import numbers
from typing import Callable, List, Optional, Tuple, Union
@ -17,7 +17,7 @@ from .wallet_service import WalletService
from jmbitcoin import make_shuffled_tx, amount_to_str, \
PartiallySignedTransaction, CMutableTxOut,\
human_readable_transaction
from jmbase.support import EXIT_SUCCESS
from jmbase.support import EXIT_SUCCESS, twisted_sys_exit
log = get_log()
"""
@ -34,7 +34,7 @@ def get_utxo_scripts(wallet: BaseWallet, utxos: dict) -> list:
script_types.append(wallet.get_outtype(utxo["address"]))
return script_types
def direct_send(wallet_service: WalletService,
async def direct_send(wallet_service: WalletService,
mixdepth: int,
dest_and_amounts: List[Tuple[str, int]],
answeryes: bool = False,
@ -128,7 +128,8 @@ def direct_send(wallet_service: WalletService,
#doing a sweep
destination = dest_and_amounts[0][0]
amount = dest_and_amounts[0][1]
utxos = wallet_service.get_utxos_by_mixdepth()[mixdepth]
_utxos = await wallet_service.get_utxos_by_mixdepth()
utxos = _utxos[mixdepth]
if utxos == {}:
log.error(
f"There are no available utxos in mixdepth {mixdepth}, "
@ -162,8 +163,8 @@ def direct_send(wallet_service: WalletService,
# of non-standard input types at this point.
initial_fee_est = estimate_tx_fee(8, len(dest_and_amounts) + 1,
txtype=txtype, outtype=outtypes)
utxos = wallet_service.select_utxos(mixdepth, amount + initial_fee_est,
includeaddr=True)
utxos = await wallet_service.select_utxos(
mixdepth, amount + initial_fee_est, includeaddr=True)
script_types = get_utxo_scripts(wallet_service.wallet, utxos)
if len(utxos) < 8:
fee_est = estimate_tx_fee(len(utxos), len(dest_and_amounts) + 1,
@ -175,7 +176,7 @@ def direct_send(wallet_service: WalletService,
outs = []
for out in dest_and_amounts:
outs.append({"value": out[1], "address": out[0]})
change_addr = wallet_service.get_internal_addr(mixdepth) \
change_addr = await wallet_service.get_internal_addr(mixdepth) \
if custom_change_addr is None else custom_change_addr
outs.append({"value": changeval, "address": change_addr})
@ -215,8 +216,10 @@ def direct_send(wallet_service: WalletService,
if with_final_psbt:
# here we have the PSBTWalletMixin do the signing stage
# for us:
new_psbt = wallet_service.create_psbt_from_tx(tx, spent_outs=spent_outs)
serialized_psbt, err = wallet_service.sign_psbt(new_psbt.serialize())
new_psbt = await wallet_service.create_psbt_from_tx(
tx, spent_outs=spent_outs)
serialized_psbt, err = await wallet_service.sign_psbt(
new_psbt.serialize())
if err:
log.error("Failed to sign PSBT, quitting. Error message: " + err)
return False
@ -225,7 +228,7 @@ def direct_send(wallet_service: WalletService,
print(wallet_service.human_readable_psbt(new_psbt_signed))
return new_psbt_signed
else:
success, msg = wallet_service.sign_tx(tx, inscripts)
success, msg = await wallet_service.sign_tx(tx, inscripts)
if not success:
log.error("Failed to sign transaction, quitting. Error msg: " + msg)
return
@ -239,13 +242,17 @@ def direct_send(wallet_service: WalletService,
log.info(sending_info)
if not answeryes:
if not accept_callback:
if not cli_prompt_user_yesno('Would you like to push to the network?'):
log.info("You chose not to broadcast the transaction, quitting.")
if not cli_prompt_user_yesno('Would you like to push to '
'the network?'):
log.info("You chose not to broadcast the transaction, "
"quitting.")
return False
else:
accepted = accept_callback(human_readable_transaction(tx),
destination, actual_amount, fee_est,
custom_change_addr)
if asyncio.iscoroutine(accepted):
accepted = await accepted
if not accepted:
return False
if change_label:
@ -258,13 +265,17 @@ def direct_send(wallet_service: WalletService,
txid = bintohex(tx.GetTxid()[::-1])
successmsg = "Transaction sent: " + txid
cb = log.info if not info_callback else info_callback
cb(successmsg)
cb_res = cb(successmsg)
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
txinfo = txid if not return_transaction else tx
return txinfo
else:
errormsg = "Transaction broadcast failed!"
cb = log.error if not error_callback else error_callback
cb(errormsg)
cb_res = cb(errormsg)
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
return False
def get_tumble_log(logsdir):
@ -304,8 +315,8 @@ def restart_wait(txid):
if res["confirmations"] == 0:
return False
if res["confirmations"] < 0:
log.warn("Tx: " + txid + " has a conflict, abandoning.")
sys.exit(EXIT_SUCCESS)
log.warning("Tx: " + txid + " has a conflict, abandoning.")
twisted_sys_exit(EXIT_SUCCESS)
else:
log.debug("Tx: " + str(txid) + " has " + str(
res["confirmations"]) + " confirmations.")

1318
src/jmclient/wallet.py

File diff suppressed because it is too large Load Diff

118
src/jmclient/wallet_rpc.py

@ -23,9 +23,13 @@ from jmclient import Taker, jm_single, \
get_schedule, get_tumbler_parser, schedule_to_text, \
tumbler_filter_orders_callback, tumbler_taker_finished_update, \
validate_address, FidelityBondMixin, BaseWallet, WalletError, \
ScheduleGenerationErrorNoFunds, BIP39WalletMixin, auth, wallet_signmessage
ScheduleGenerationErrorNoFunds, BIP39WalletMixin, auth, \
wallet_signmessage, FrostWallet
from jmbase.support import get_log, utxostr_to_utxo, JM_CORE_VERSION
from .frost_ipc import FrostIPCClient
jlog = get_log()
api_version_string = "/api/v1"
@ -192,7 +196,9 @@ class JMWalletDaemon(Service):
"tx_fees")
def get_client_factory(self):
return JMClientProtocolFactory(self.taker)
cfactory = JMClientProtocolFactory(self.taker)
wallet = self.services["wallet"]
return cfactory
def activate_coinjoin_state(self, state):
""" To be set when a maker or taker
@ -482,7 +488,8 @@ class JMWalletDaemon(Service):
# balances; there are various approaches to passing warnings
# or requesting rescans, none are implemented yet.
def dummy_restart_callback(msg):
jlog.warn("Ignoring rescan request from backend wallet service: " + msg)
jlog.warning("Ignoring rescan request from backend wallet"
" service: " + msg)
self.services["wallet"].add_restart_callback(dummy_restart_callback)
self.active_session = True
self.wss_factory.active_session = True
@ -520,7 +527,7 @@ class JMWalletDaemon(Service):
# We're running the tumbler.
assert self.tumble_log is not None
logsdir = os.path.join(os.path.dirname(jm_single().config_location), "logs")
logsdir = os.path.join(jm_single().datadir, "logs")
sfile = os.path.join(logsdir, self.tumbler_options['schedulefile'])
tumbler_taker_finished_update(self.taker, sfile, self.tumble_log, self.tumbler_options, res, fromtx, waittime, txdetails)
@ -556,7 +563,7 @@ class JMWalletDaemon(Service):
except Exception as e:
# Should not happen, but avoid crash if trying to
# shut down something that already disconnected:
jlog.warn("Failed to shut down connection: " + repr(e))
jlog.warning("Failed to shut down connection: " + repr(e))
self.coinjoin_connection = None
def filter_orders_callback(self,orderfees, cjamount):
@ -656,17 +663,18 @@ class JMWalletDaemon(Service):
)
@app.route('/wallet/<string:walletname>/display', methods=['GET'])
def displaywallet(self, request, walletname):
async def displaywallet(self, request, walletname):
print_req(request)
self.check_cookie(request)
if not self.services["wallet"]:
jlog.warn("displaywallet called, but no wallet loaded")
jlog.warning("displaywallet called, but no wallet loaded")
raise NoWalletFound()
if not self.wallet_name == walletname:
jlog.warn("called displaywallet with wrong wallet")
jlog.warning("called displaywallet with wrong wallet")
raise InvalidRequestFormat()
else:
walletinfo = wallet_display(self.services["wallet"], False, jsonified=True)
walletinfo = await wallet_display(
self.services["wallet"], False, jsonified=True)
return make_jmwalletd_response(request, walletname=walletname, walletinfo=walletinfo)
@app.route('/wallet/<string:walletname>/rescanblockchain/<int:blockheight>', methods=['GET'])
@ -683,10 +691,10 @@ class JMWalletDaemon(Service):
print_req(request)
self.check_cookie(request)
if not self.services["wallet"]:
jlog.warn("rescanblockchain called, but no wallet service active.")
jlog.warning("rescanblockchain called, but no wallet service active.")
raise NoWalletFound()
if not self.wallet_name == walletname:
jlog.warn("called rescanblockchain with wrong wallet")
jlog.warning("called rescanblockchain with wrong wallet")
raise InvalidRequestFormat()
else:
self.services["wallet"].rescanblockchain(blockheight)
@ -699,10 +707,11 @@ class JMWalletDaemon(Service):
print_req(request)
self.check_cookie(request)
if not self.services["wallet"]:
jlog.warn("getrescaninfo called, but no wallet service active.")
jlog.warning("getrescaninfo called, but no wallet"
" service active.")
raise NoWalletFound()
if not self.wallet_name == walletname:
jlog.warn("called getrescaninfo with wrong wallet")
jlog.warning("called getrescaninfo with wrong wallet")
raise InvalidRequestFormat()
else:
rescanning, progress = \
@ -787,7 +796,7 @@ class JMWalletDaemon(Service):
)
@app.route('/wallet/<string:walletname>/taker/direct-send', methods=['POST'])
def directsend(self, request, walletname):
async def directsend(self, request, walletname):
""" Use the contents of the POST body to do a direct send from
the active wallet at the chosen mixdepth.
"""
@ -818,13 +827,14 @@ class JMWalletDaemon(Service):
raise InvalidRequestFormat()
try:
tx = direct_send(self.services["wallet"],
int(payment_info_json["mixdepth"]),
[(
payment_info_json["destination"],
int(payment_info_json["amount_sats"])
)],
return_transaction=True, answeryes=True)
tx = await direct_send(
self.services["wallet"],
int(payment_info_json["mixdepth"]),
[(
payment_info_json["destination"],
int(payment_info_json["amount_sats"])
)],
return_transaction=True, answeryes=True)
jm_single().config.set("POLICY", "tx_fees",
self.default_policy_tx_fees)
except AssertionError:
@ -847,7 +857,7 @@ class JMWalletDaemon(Service):
txinfo=human_readable_transaction(tx, False))
@app.route('/wallet/<string:walletname>/maker/start', methods=['POST'])
def start_maker(self, request, walletname):
async def start_maker(self, request, walletname):
""" Use the configuration in the POST body to start the yield generator:
"""
print_req(request)
@ -894,7 +904,7 @@ class JMWalletDaemon(Service):
raise ServiceAlreadyStarted()
# don't even start up the service if there aren't any coins
# to offer:
def setup_sanitycheck_balance():
async def setup_sanitycheck_balance():
# note: this will only be non-zero if coins are confirmed.
# note: a call to start_maker necessarily is after a successful
# sync has already happened (this is different from CLI yg).
@ -910,7 +920,7 @@ class JMWalletDaemon(Service):
# We must also not start if the only coins available are of
# the TL type *even* if the TL is expired. This check is done
# here early, as above, to avoid the maker service starting.
utxos = self.services["wallet"].get_all_utxos()
utxos = await self.services["wallet"].get_all_utxos()
# remove any TL type:
utxos = [u for u in utxos.values() if not \
FidelityBondMixin.is_timelocked_path(u["path"])]
@ -927,7 +937,9 @@ class JMWalletDaemon(Service):
self.services["maker"].addSetup(setup_set_coinjoin_state)
# Service startup now checks and updates coinjoin state,
# assuming setup is successful:
self.services["maker"].startService()
exc = await self.services["maker"].startService()
if exc:
raise exc
return make_jmwalletd_response(request, status=202)
@ -956,7 +968,8 @@ class JMWalletDaemon(Service):
yigen_data = f.readlines()
return yigen_data
except Exception as e:
jlog.warn("Yigen report failed to find file: {}".format(repr(e)))
jlog.warning("Yigen report failed to find"
" file: {}".format(repr(e)))
raise YieldGeneratorDataUnreadable()
@app.route('/wallet/yieldgen/report', methods=['GET'])
@ -980,7 +993,7 @@ class JMWalletDaemon(Service):
if self.services["wallet"] and not self.wallet_name == walletname:
raise InvalidRequestFormat()
if not self.services["wallet"]:
jlog.warn("Called lock, but no wallet loaded")
jlog.warning("Called lock, but no wallet loaded")
# we could raise NoWalletFound here, but is
# easier for clients if they can gracefully call
# lock multiple times:
@ -997,7 +1010,7 @@ class JMWalletDaemon(Service):
already_locked=already_locked)
@app.route('/wallet/create', methods=["POST"])
def createwallet(self, request):
async def createwallet(self, request):
print_req(request)
# we only handle one wallet at a time;
# if there is a currently unlocked wallet,
@ -1011,7 +1024,7 @@ class JMWalletDaemon(Service):
wallet_cls = self.get_wallet_cls_from_type(
request_data["wallettype"])
try:
wallet = create_wallet(self.get_wallet_name_from_req(
wallet = await create_wallet(self.get_wallet_name_from_req(
request_data["walletname"]),
request_data["password"].encode("ascii"),
4, wallet_cls=wallet_cls)
@ -1028,7 +1041,7 @@ class JMWalletDaemon(Service):
seedphrase=seed)
@app.route('/wallet/recover', methods=["POST"])
def recoverwallet(self, request):
async def recoverwallet(self, request):
print_req(request)
# we only handle one wallet at a time;
# if there is a currently unlocked wallet,
@ -1052,7 +1065,8 @@ class JMWalletDaemon(Service):
# should only occur if the seedphrase is not valid BIP39:
raise InvalidRequestFormat()
try:
wallet = create_wallet(self.get_wallet_name_from_req(
wallet = await create_wallet(
self.get_wallet_name_from_req(
request_data["walletname"]),
request_data["password"].encode("ascii"),
4, wallet_cls=wallet_cls, entropy=entropy)
@ -1067,7 +1081,7 @@ class JMWalletDaemon(Service):
seedphrase=seedphrase)
@app.route('/wallet/<string:walletname>/unlock', methods=['POST'])
def unlockwallet(self, request, walletname):
async def unlockwallet(self, request, walletname):
""" If a user succeeds in authenticating and opening a
wallet, we start the corresponding wallet service.
Notice that in the case the user fails for any reason,
@ -1095,7 +1109,7 @@ class JMWalletDaemon(Service):
if walletname == self.wallet_name:
try:
# returned wallet object is ditched:
open_test_wallet_maybe(
await open_test_wallet_maybe(
wallet_path, walletname, 4,
password=password.encode("utf-8"),
ask_for_password=False,
@ -1120,11 +1134,15 @@ class JMWalletDaemon(Service):
# This is a different wallet than the one currently open;
# try to open it, then initialize the service(s):
try:
wallet = open_test_wallet_maybe(
wallet = await open_test_wallet_maybe(
wallet_path, walletname, 4,
password=password.encode("utf-8"),
ask_for_password=False,
gap_limit = jm_single().config.getint("POLICY", "gaplimit"))
if isinstance(wallet, FrostWallet):
ipc_client = FrostIPCClient(wallet)
await ipc_client.async_init()
wallet.set_ipc_client(ipc_client)
except StoragePasswordError:
raise InvalidCredentials()
except RetryableStorageError:
@ -1156,7 +1174,7 @@ class JMWalletDaemon(Service):
#route to get external address for deposit
@app.route('/wallet/<string:walletname>/address/new/<string:mixdepth>', methods=['GET'])
def getaddress(self, request, walletname, mixdepth):
async def getaddress(self, request, walletname, mixdepth):
self.check_cookie(request)
if not self.services["wallet"]:
raise NoWalletFound()
@ -1166,18 +1184,18 @@ class JMWalletDaemon(Service):
mixdepth = int(mixdepth)
except ValueError:
raise InvalidRequestFormat()
address = self.services["wallet"].get_external_addr(mixdepth)
address = await self.services["wallet"].get_external_addr(mixdepth)
return make_jmwalletd_response(request, address=address)
@app.route('/wallet/<string:walletname>/address/timelock/new/<string:lockdate>', methods=['GET'])
def gettimelockaddress(self, request, walletname, lockdate):
async def gettimelockaddress(self, request, walletname, lockdate):
self.check_cookie(request)
if not self.services["wallet"]:
raise NoWalletFound()
if not self.wallet_name == walletname:
raise InvalidRequestFormat()
try:
timelockaddress = wallet_gettimelockaddress(
timelockaddress = await wallet_gettimelockaddress(
self.services["wallet"].wallet, lockdate)
except Exception:
raise InvalidRequestFormat()
@ -1271,7 +1289,7 @@ class JMWalletDaemon(Service):
#route to list utxos
@app.route('/wallet/<string:walletname>/utxos',methods=['GET'])
def listutxos(self, request, walletname):
async def listutxos(self, request, walletname):
self.check_cookie(request)
if not self.services["wallet"]:
raise NoWalletFound()
@ -1279,7 +1297,8 @@ class JMWalletDaemon(Service):
raise InvalidRequestFormat()
# note: the output of `showutxos` is already a string for CLI;
# but we return json:
utxos = json.loads(wallet_showutxos(self.services["wallet"], False))
utxos = json.loads(
await wallet_showutxos(self.services["wallet"], False))
utxos_response = self.get_listutxos_response(utxos)
return make_jmwalletd_response(request, utxos=utxos_response)
@ -1301,7 +1320,7 @@ class JMWalletDaemon(Service):
#route to start a coinjoin transaction
@app.route('/wallet/<string:walletname>/taker/coinjoin', methods=['POST'])
def docoinjoin(self, request, walletname):
async def docoinjoin(self, request, walletname):
self.check_cookie(request)
if not self.services["wallet"]:
raise NoWalletFound()
@ -1338,8 +1357,8 @@ class JMWalletDaemon(Service):
# why no defaults).
def dummy_user_callback(rel, abs):
raise ConfigNotPresent()
max_cj_fee= get_max_cj_fee_values(jm_single().config,
None, user_callback=dummy_user_callback)
max_cj_fee= await get_max_cj_fee_values(
jm_single().config, None, user_callback=dummy_user_callback)
# Before actual start, update our coinjoin state:
if not self.activate_coinjoin_state(CJ_TAKER_RUNNING):
raise ServiceAlreadyStarted()
@ -1395,7 +1414,7 @@ class JMWalletDaemon(Service):
signature=result[0], message=result[1], address=result[2])
@app.route('/wallet/<string:walletname>/taker/schedule', methods=['POST'])
def start_tumbler(self, request, walletname):
async def start_tumbler(self, request, walletname):
self.check_cookie(request)
if self.coinjoin_state is not CJ_NOT_RUNNING or self.tumbler_options is not None:
@ -1437,9 +1456,8 @@ class JMWalletDaemon(Service):
def dummy_user_callback(rel, abs):
raise ConfigNotPresent()
max_cj_fee = get_max_cj_fee_values(jm_single().config,
None,
user_callback=dummy_user_callback)
max_cj_fee = await get_max_cj_fee_values(
jm_single().config, None, user_callback=dummy_user_callback)
jm_single().mincjamount = tumbler_options['mincjamount']
@ -1453,8 +1471,7 @@ class JMWalletDaemon(Service):
except ScheduleGenerationErrorNoFunds:
raise NotEnoughCoinsForTumbler()
logsdir = os.path.join(os.path.dirname(jm_single().config_location),
"logs")
logsdir = os.path.join(jm_single().datadir, "logs")
sfile = os.path.join(logsdir, tumbler_options['schedulefile'])
with open(sfile, "wb") as f:
f.write(schedule_to_text(schedule))
@ -1477,7 +1494,6 @@ class JMWalletDaemon(Service):
raise ServiceAlreadyStarted()
self.tumbler_options = tumbler_options
self.taker = Taker(self.services["wallet"],
schedule,
max_cj_fee=max_cj_fee,
@ -1508,7 +1524,7 @@ class JMWalletDaemon(Service):
if not self.tumbler_options or not self.coinjoin_state == CJ_TAKER_RUNNING:
return make_jmwalletd_response(request, status=404)
logsdir = os.path.join(os.path.dirname(jm_single().config_location), "logs")
logsdir = os.path.join(jm_single().datadir, "logs")
sfile = os.path.join(logsdir, self.tumbler_options['schedulefile'])
res, schedule = get_schedule(sfile)

341
src/jmclient/wallet_service.py

@ -1,14 +1,13 @@
#! /usr/bin/env python
import asyncio
import collections
import itertools
import time
import sys
from typing import Dict, List, Optional, Set, Tuple
from decimal import Decimal
from copy import deepcopy
from twisted.internet import reactor
from twisted.internet import task
from twisted.application.service import Service
from numbers import Integral
import jmbitcoin as btc
@ -16,9 +15,12 @@ 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, BaseWallet
from jmclient.wallet import (FidelityBondMixin, BaseWallet, TaprootWallet,
FrostWallet)
from jmbase import (stop_reactor, hextobin, utxo_to_utxostr,
jmprint, EXIT_SUCCESS, EXIT_FAILURE)
twisted_sys_exit, jmprint, EXIT_SUCCESS, EXIT_FAILURE,
is_running_from_pytest)
from .descriptor import descsum_create
"""Wallet service
@ -42,9 +44,10 @@ class WalletService(Service):
# the JM wallet object.
self.bci = jm_single().bc_interface
# main loop used to check for transactions, instantiated
# main task used to check for transactions, instantiated
# after wallet is synced:
self.monitor_loop = None
self.service_task = None
self.monitor_task = None
self.wallet = wallet
self.synced = False
@ -129,8 +132,8 @@ class WalletService(Service):
assert isinstance(self.current_blockheight, Integral)
assert self.current_blockheight >= 0
if self.current_blockheight < old_blockheight:
jlog.warn("Bitcoin Core is reporting a lower blockheight, "
"possibly a reorg.")
jlog.warning("Bitcoin Core is reporting a lower blockheight, "
"possibly a reorg.")
return True
def startService(self):
@ -138,7 +141,7 @@ class WalletService(Service):
Here wallet sync.
"""
super().startService()
self.request_sync_wallet()
self.service_task = asyncio.create_task(self.request_sync_wallet())
def stopService(self):
""" Encapsulates shut down actions.
@ -146,8 +149,12 @@ class WalletService(Service):
should *not* be restarted, instead a new
WalletService instance should be created.
"""
if self.monitor_loop and self.monitor_loop.running:
self.monitor_loop.stop()
if self.monitor_task and not self.monitor_task.done():
self.monitor_task.cancel()
if self.service_task and not self.service_task.done():
self.service_task.cancel()
self.monitor_task = None
self.service_task = None
self.wallet.close()
super().stopService()
@ -166,13 +173,13 @@ class WalletService(Service):
"""
self.restart_callback = callback
def request_sync_wallet(self):
async def request_sync_wallet(self):
""" Ensures wallet sync is complete
before the main event loop starts.
"""
if self.bci is not None:
d = task.deferLater(reactor, 0.0, self.sync_wallet)
d.addCallback(self.start_wallet_monitoring)
syncresult = await self.sync_wallet()
await self.start_wallet_monitoring(syncresult)
def register_callbacks(self, callbacks, txinfo, cb_type="all"):
""" Register callbacks that will be called by the
@ -213,7 +220,7 @@ class WalletService(Service):
assert False, "Invalid argument: " + cb_type
def start_wallet_monitoring(self, syncresult):
async def start_wallet_monitoring(self, syncresult):
""" Once the initialization of the service
(currently, means: wallet sync) is complete,
we start the main monitoring jobs of the
@ -229,9 +236,16 @@ class WalletService(Service):
reactor.stop()
return
jlog.info("Starting transaction monitor in walletservice")
self.monitor_loop = task.LoopingCall(
self.transaction_monitor)
self.monitor_loop.start(5.0)
async def monitor_task():
while True:
try:
await self.transaction_monitor()
await asyncio.sleep(5)
except asyncio.CancelledError:
break
self.monitor_task = asyncio.create_task(monitor_task())
def import_non_wallet_address(self, address):
""" Used for keeping track of transactions which
@ -308,9 +322,9 @@ class WalletService(Service):
last = False
yield tx
def transaction_monitor(self):
async def transaction_monitor(self):
"""Keeps track of any changes in the wallet (new transactions).
Intended to be run as a twisted task.LoopingCall so that this
Intended to be run as a asyncio Task so that this
Service is constantly in near-realtime sync with the blockchain.
"""
@ -353,14 +367,15 @@ class WalletService(Service):
self.bci.get_deser_from_gettransaction(res)
if txd is None:
continue
removed_utxos, added_utxos = self.wallet.process_new_tx(txd, height)
removed_utxos, added_utxos = await self.wallet.process_new_tx(
txd, height)
if txid not in self.processed_txids:
# apply checks to disable/freeze utxos to reused addrs if needed:
self.check_for_reuse(added_utxos)
# TODO note that this log message will be missed if confirmation
# is absurdly fast, this is considered acceptable compared with
# additional complexity.
self.log_new_tx(removed_utxos, added_utxos, txid)
await self.log_new_tx(removed_utxos, added_utxos, txid)
self.processed_txids.add(txid)
# first fire 'all' type callbacks, irrespective of if the
@ -374,7 +389,9 @@ class WalletService(Service):
for f in self.callbacks["all"]:
# note we need no return value as we will never
# remove these from the list
f(txd, txid)
cb_res = f(txd, txid)
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
# txid is not always available at the time of callback registration.
# Migrate any callbacks registered under the provisional key, and
@ -400,9 +417,13 @@ class WalletService(Service):
if len(added_utxos) > 0 or len(removed_utxos) > 0 \
or txid in self.active_txs:
if confs == 0:
callbacks = [f for f in
self.callbacks["unconfirmed"].pop(txid, [])
if not f(txd, txid)]
callbacks = []
for f in self.callbacks["unconfirmed"].pop(txid, []):
cb_res = f(txd, txid)
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
if not cb_res:
callbacks.append(f)
if callbacks:
self.callbacks["unconfirmed"][txid] = callbacks
else:
@ -413,9 +434,13 @@ class WalletService(Service):
# the height of the utxo in UtxoManager
self.active_txs[txid] = txd
elif confs > 0:
callbacks = [f for f in
self.callbacks["confirmed"].pop(txid, [])
if not f(txd, txid, confs)]
callbacks = []
for f in self.callbacks["confirmed"].pop(txid, []):
cb_res = f(txd, txid, confs)
if asyncio.iscoroutine(cb_res):
cb_res = await cb_res
if not cb_res:
callbacks.append(f)
if callbacks:
self.callbacks["confirmed"][txid] = callbacks
else:
@ -457,25 +482,26 @@ class WalletService(Service):
# processed and so do nothing.
return True
def log_new_tx(self, removed_utxos, added_utxos, txid):
async def log_new_tx(self, removed_utxos, added_utxos, txid):
""" Changes to the wallet are logged at INFO level by
the WalletService.
"""
def report_changed(x, utxos):
async def report_changed(x, utxos):
if len(utxos.keys()) > 0:
jlog.info(x + ' utxos=\n{}'.format('\n'.join(
'{} - {}'.format(utxo_to_utxostr(u)[1],
fmt_tx_data(tx_data, self)) for u,
tx_data in utxos.items())))
['{} - {}'.format(
utxo_to_utxostr(u)[1],
await fmt_tx_data(tx_data, self))
for u, tx_data in utxos.items()])))
report_changed("Removed", removed_utxos)
report_changed("Added", added_utxos)
await report_changed("Removed", removed_utxos)
await report_changed("Added", added_utxos)
""" Wallet syncing code
"""
def sync_wallet(self, fast=True):
async def sync_wallet(self, fast=True):
""" Syncs wallet; note that if slow sync
requires multiple rounds this must be called
until self.synced is True.
@ -488,9 +514,9 @@ class WalletService(Service):
if self.synced:
return True
if fast:
self.sync_wallet_fast()
await self.sync_wallet_fast()
else:
self.sync_addresses()
await self.sync_addresses()
self.sync_unspent()
# Don't attempt updates on transactions that existed
# before startup
@ -501,22 +527,22 @@ class WalletService(Service):
self.bci.set_wallet_no_history(self.wallet)
return self.synced
def resync_wallet(self, fast=True):
async def resync_wallet(self, fast=True):
""" The self.synced state is generally
updated to True, once, at the start of
a run of a particular program. Here we
can manually force re-sync.
"""
self.synced = False
self.sync_wallet(fast=fast)
await self.sync_wallet(fast=fast)
def sync_wallet_fast(self):
async def sync_wallet_fast(self):
"""Exploits the fact that given an index_cache,
all addresses necessary should be imported, so we
can just list all used addresses to find the right
index values.
"""
self.sync_addresses_fast()
await self.sync_addresses_fast()
self.sync_unspent()
def has_address_been_used(self, address):
@ -544,7 +570,7 @@ class WalletService(Service):
used_addresses.add(addr_info[0])
self.used_addresses = used_addresses
def sync_addresses_fast(self):
async def sync_addresses_fast(self):
"""Locates all used addresses in the account (whether spent or
unspent outputs), and then, assuming that all such usages must be
related to our wallet, calculates the correct wallet indices and
@ -561,7 +587,7 @@ class WalletService(Service):
# delegate inital address import to sync_addresses
# this should be fast because "getaddressesbyaccount" should return
# an empty list in this case
self.sync_addresses()
await self.sync_addresses()
self.synced = True
return
@ -591,9 +617,15 @@ class WalletService(Service):
# showing imported addresses. Hence the gap-limit import at the end
# to ensure this is always true.
remaining_used_addresses = self.used_addresses.copy()
addresses, saved_indices = self.collect_addresses_init()
for addr in addresses:
remaining_used_addresses.discard(addr)
if isinstance(self.wallet, (TaprootWallet, FrostWallet)):
pubkeys, saved_indices = await self.collect_pubkeys_init()
for pubkey in pubkeys:
addr = self.wallet.pubkey_to_addr(pubkey)
remaining_used_addresses.discard(addr)
else:
addresses, saved_indices = await self.collect_addresses_init()
for addr in addresses:
remaining_used_addresses.discard(addr)
BATCH_SIZE = 100
MAX_ITERATIONS = 20
@ -601,12 +633,20 @@ class WalletService(Service):
for j in range(MAX_ITERATIONS):
if not remaining_used_addresses:
break
gap_addrs = self.collect_addresses_gap(gap_limit=BATCH_SIZE)
# note: gap addresses *not* imported here; we are still trying
# to find the highest-index used address, and assume that imports
# are up to that index (at least) - see above main rationale.
for addr in gap_addrs:
remaining_used_addresses.discard(addr)
if isinstance(self.wallet, (TaprootWallet, FrostWallet)):
gap_pubkeys = await self.collect_pubkeys_gap(
gap_limit=BATCH_SIZE)
for pubkey in gap_pubkeys:
addr = self.wallet.pubkey_to_addr(pubkey)
remaining_used_addresses.discard(addr)
else:
gap_addrs = await self.collect_addresses_gap(
gap_limit=BATCH_SIZE)
for addr in gap_addrs:
remaining_used_addresses.discard(addr)
# increase wallet indices for next iteration
for md in current_indices:
@ -626,8 +666,21 @@ class WalletService(Service):
# we ensure that all addresses that will be displayed (see wallet_utils.py,
# function wallet_display()) are imported by importing gap limit beyond current
# index:
self.bci.import_addresses(self.collect_addresses_gap(), self.get_wallet_name(),
self.restart_callback)
if isinstance(self.wallet, (TaprootWallet, FrostWallet)):
pubkeys = await self.collect_pubkeys_gap()
desc_scripts = [f'tr({bytes(P)[1:].hex()})' for P in pubkeys]
descriptors = set()
for desc in desc_scripts:
descriptors.add(f'{descsum_create(desc)}')
self.bci.import_descriptors(
descriptors,
self.get_wallet_name(),
self.restart_callback)
else:
self.bci.import_addresses(
await self.collect_addresses_gap(),
self.get_wallet_name(),
self.restart_callback)
if isinstance(self.wallet, FidelityBondMixin):
mixdepth = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH
@ -649,7 +702,8 @@ class WalletService(Service):
#theres also a sys.exit() in BitcoinCoreInterface.import_addresses()
#perhaps have sys.exit() placed inside the restart_cb that only
# CLI scripts will use
if self.bci.__class__ == BitcoinCoreInterface:
if (isinstance(self.bci, BitcoinCoreInterface)
and not is_running_from_pytest()):
#Exit conditions cannot be included in tests
restart_msg = ("Use `bitcoin-cli rescanblockchain` if you're "
"recovering an existing wallet from backup seed\n"
@ -658,7 +712,7 @@ class WalletService(Service):
restart_cb(restart_msg)
else:
jmprint(restart_msg, "important")
sys.exit(EXIT_SUCCESS)
twisted_sys_exit(EXIT_SUCCESS)
def sync_burner_outputs(self, burner_txes):
mixdepth = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH
@ -746,7 +800,7 @@ class WalletService(Service):
return self.bci.get_block_height(self.bci.get_transaction(
txid)["blockhash"])
def sync_addresses(self):
async def sync_addresses(self):
""" Triggered by use of --recoversync option in scripts,
attempts a full scan of the blockchain without assuming
anything about past usages of addresses (does not use
@ -754,10 +808,19 @@ class WalletService(Service):
"""
jlog.debug("requesting detailed wallet history")
wallet_name = self.get_wallet_name()
addresses, saved_indices = self.collect_addresses_init()
import_needed = self.bci.import_addresses_if_needed(addresses,
wallet_name)
if isinstance(self.wallet, (TaprootWallet, FrostWallet)):
pubkeys, saved_indices = await self.collect_pubkeys_init()
desc_scripts = [f'tr({bytes(P)[1:].hex()})' for P in pubkeys]
descriptors= set()
for desc in desc_scripts:
descriptors.add(f'{descsum_create(desc)}')
import_needed = self.bci.import_descriptors_if_needed(
descriptors, wallet_name)
else:
addresses, saved_indices = await self.collect_addresses_init()
import_needed = self.bci.import_addresses_if_needed(
addresses, wallet_name)
if import_needed:
self.display_rescan_message_and_system_exit(self.restart_callback)
return
@ -797,18 +860,36 @@ class WalletService(Service):
gap_limit_used = not self.check_gap_indices(used_indices)
self.rewind_wallet_indices(used_indices, saved_indices)
new_addresses = self.collect_addresses_gap()
if self.bci.import_addresses_if_needed(new_addresses, wallet_name):
jlog.debug("Syncing iteration finished, additional step required (more address import required)")
self.synced = False
self.display_rescan_message_and_system_exit(self.restart_callback)
elif gap_limit_used:
jlog.debug("Syncing iteration finished, additional step required (gap limit used)")
self.synced = False
if isinstance(self.wallet, (TaprootWallet, FrostWallet)):
new_pubkeys = await self.collect_pubkeys_gap()
desc_scripts = [f'tr({bytes(P)[1:].hex()})' for P in new_pubkeys]
descriptors= set()
for desc in desc_scripts:
descriptors.add(f'{descsum_create(desc)}')
if self.bci.import_descriptors_if_needed(descriptors, wallet_name):
jlog.debug("Syncing iteration finished, additional step required (more pubkey import required)")
self.synced = False
self.display_rescan_message_and_system_exit(self.restart_callback)
elif gap_limit_used:
jlog.debug("Syncing iteration finished, additional step required (gap limit used)")
self.synced = False
else:
jlog.debug("Wallet successfully synced")
self.rewind_wallet_indices(used_indices, saved_indices)
self.synced = True
else:
jlog.debug("Wallet successfully synced")
self.rewind_wallet_indices(used_indices, saved_indices)
self.synced = True
new_addresses = await self.collect_addresses_gap()
if self.bci.import_addresses_if_needed(new_addresses, wallet_name):
jlog.debug("Syncing iteration finished, additional step required (more address import required)")
self.synced = False
self.display_rescan_message_and_system_exit(self.restart_callback)
elif gap_limit_used:
jlog.debug("Syncing iteration finished, additional step required (gap limit used)")
self.synced = False
else:
jlog.debug("Wallet successfully synced")
self.rewind_wallet_indices(used_indices, saved_indices)
self.synced = True
def sync_unspent(self):
st = time.time()
@ -832,7 +913,7 @@ class WalletService(Service):
if self.isRunning:
self.stopService()
stop_reactor()
sys.exit(EXIT_FAILURE)
twisted_sys_exit(EXIT_FAILURE)
wallet_name = self.get_wallet_name()
self.reset_utxos()
@ -900,11 +981,11 @@ class WalletService(Service):
def save_wallet(self):
self.wallet.save()
def get_utxos_by_mixdepth(self, include_disabled: bool = False,
verbose: bool = False,
includeconfs: bool = False,
limit_mixdepth: Optional[int] = None
) -> collections.defaultdict:
async def get_utxos_by_mixdepth(self, include_disabled: bool = False,
verbose: bool = False,
includeconfs: bool = False,
limit_mixdepth: Optional[int] = None
) -> collections.defaultdict:
""" Returns utxos by mixdepth in a dict, optionally including
information about how many confirmations each utxo has.
"""
@ -921,7 +1002,7 @@ class WalletService(Service):
confs = self.current_blockheight - h + 1
ubym_conv[m][u]["confs"] = confs
return ubym_conv
ubym = self.wallet.get_utxos_by_mixdepth(
ubym = await self.wallet.get_utxos_by_mixdepth(
include_disabled=include_disabled, includeheight=includeconfs,
limit_mixdepth=limit_mixdepth)
if not includeconfs:
@ -935,15 +1016,16 @@ class WalletService(Service):
else:
return self.current_blockheight - minconfs + 1
def select_utxos(self, mixdepth, amount, utxo_filter=None, select_fn=None,
minconfs=None, includeaddr=False, require_auth_address=False):
async def select_utxos(self, mixdepth, amount, utxo_filter=None,
select_fn=None, minconfs=None, includeaddr=False,
require_auth_address=False):
""" Request utxos from the wallet in a particular mixdepth to satisfy
a certain total amount, optionally set the selector function (or use
the currently configured function set by the wallet, and optionally
require a minimum of minconfs confirmations (default none means
unconfirmed are allowed).
"""
return self.wallet.select_utxos(
return await self.wallet.select_utxos(
mixdepth, amount, utxo_filter=utxo_filter, select_fn=select_fn,
maxheight=self.minconfs_to_maxheight(minconfs),
includeaddr=includeaddr, require_auth_address=require_auth_address)
@ -963,12 +1045,24 @@ class WalletService(Service):
if self.bci is not None and hasattr(self.bci, 'import_addresses'):
self.bci.import_addresses([addr], self.wallet.get_wallet_name())
def get_internal_addr(self, mixdepth):
addr = self.wallet.get_internal_addr(mixdepth)
self.import_addr(addr)
return addr
def import_pubkey(self, pubkey):
if self.bci is not None and hasattr(self.bci, 'import_descriptors'):
desc_script = f'tr({bytes(pubkey)[1:].hex()})'
descriptor = descsum_create(desc_script)
self.bci.import_descriptors(
[descriptor], self.wallet.get_wallet_name())
async def get_internal_addr(self, mixdepth):
if isinstance(self.wallet, (TaprootWallet, FrostWallet)):
pubkey = await self.wallet.get_internal_pubkey(mixdepth)
self.import_pubkey(pubkey)
return self.wallet.pubkey_to_addr(pubkey)
else:
addr = await self.wallet.get_internal_addr(mixdepth)
self.import_addr(addr)
return addr
def collect_addresses_init(self) -> Tuple[Set[str], Dict[int, List[int]]]:
async def collect_addresses_init(self) -> Tuple[Set[str], Dict[int, List[int]]]:
""" Collects the "current" set of addresses,
as defined by the indices recorded in the wallet's
index cache (persisted in the wallet file usually).
@ -984,25 +1078,58 @@ class WalletService(Service):
BaseWallet.ADDRESS_TYPE_INTERNAL):
next_unused = self.get_next_unused_index(md, address_type)
for index in range(next_unused):
addresses.add(self.get_addr(md, address_type, index))
addresses.add(
await self.get_addr(md, address_type, index))
for index in range(self.gap_limit):
addresses.add(self.get_new_addr(md, address_type,
validate_cache=False))
addresses.add(
await self.get_new_addr(
md, address_type, validate_cache=False))
# reset the indices to the value we had before the
# new address calls:
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))
addresses.add(await self.get_address_from_path(path))
if isinstance(self.wallet, FidelityBondMixin):
md = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH
address_type = FidelityBondMixin.BIP32_TIMELOCK_ID
for timenumber in range(FidelityBondMixin.TIMENUMBER_COUNT):
addresses.add(self.get_addr(md, address_type, timenumber))
addresses.add(
await self.get_addr(md, address_type, timenumber))
return addresses, saved_indices
def collect_addresses_gap(self, gap_limit=None):
async def collect_pubkeys_init(
self
) -> Tuple[Set[str], Dict[int, List[int]]]:
""" Collects the "current" set of pubkeys,
as defined by the indices recorded in the wallet's
index cache (persisted in the wallet file usually).
Note that it collects up to the current indices plus
the gap limit.
"""
pubkeys = set()
saved_indices = dict()
for md in range(self.max_mixdepth + 1):
saved_indices[md] = [0, 0]
for address_type in (BaseWallet.ADDRESS_TYPE_EXTERNAL,
BaseWallet.ADDRESS_TYPE_INTERNAL):
next_unused = self.get_next_unused_index(md, address_type)
for index in range(next_unused):
pubkey = await self.get_pubkey(md, address_type, index)
pubkeys.add(pubkey)
for index in range(self.gap_limit):
pubkey = await self.get_new_pubkey(
md, address_type, validate_cache=False)
pubkeys.add(pubkey)
# reset the indices to the value we had before the
# new address calls:
self.set_next_index(md, address_type, next_unused)
saved_indices[md][address_type] = next_unused
return pubkeys, saved_indices
async def collect_addresses_gap(self, gap_limit=None):
gap_limit = gap_limit or self.gap_limit
addresses = set()
for md in range(self.max_mixdepth + 1):
@ -1010,15 +1137,35 @@ class WalletService(Service):
BaseWallet.ADDRESS_TYPE_EXTERNAL):
old_next = self.get_next_unused_index(md, address_type)
for index in range(gap_limit):
addresses.add(self.get_new_addr(md, address_type,
validate_cache=False))
addresses.add(
await self.get_new_addr(
md, address_type, validate_cache=False))
self.set_next_index(md, address_type, old_next)
return addresses
def get_external_addr(self, mixdepth):
addr = self.wallet.get_external_addr(mixdepth)
self.import_addr(addr)
return addr
async def collect_pubkeys_gap(self, gap_limit=None):
gap_limit = gap_limit or self.gap_limit
pubkeys = set()
for md in range(self.max_mixdepth + 1):
for address_type in (BaseWallet.ADDRESS_TYPE_INTERNAL,
BaseWallet.ADDRESS_TYPE_EXTERNAL):
old_next = self.get_next_unused_index(md, address_type)
for index in range(gap_limit):
pubkey = await self.get_new_pubkey(
md, address_type, validate_cache=False)
pubkeys.add(pubkey)
self.set_next_index(md, address_type, old_next)
return pubkeys
async def get_external_addr(self, mixdepth):
if isinstance(self.wallet, (TaprootWallet, FrostWallet)):
pubkey = await self.wallet.get_external_pubkey(mixdepth)
self.import_pubkey(pubkey)
return self.wallet.pubkey_to_addr(pubkey)
else:
addr = await self.wallet.get_external_addr(mixdepth)
self.import_addr(addr)
return addr
def __getattr__(self, attr):
# any method not present here is passed

596
src/jmclient/wallet_utils.py

File diff suppressed because it is too large Load Diff

4
src/jmclient/websocketserver.py

@ -20,8 +20,8 @@ class JmwalletdWebSocketServerProtocol(WebSocketServerProtocol):
if not self.active_session:
# not sending any data if the session is
# not active, i.e. client hasn't authenticated.
jlog.warn("Websocket not sending notification, "
"the session is not active.")
jlog.warning("Websocket not sending notification, "
"the session is not active.")
return
self.sendMessage(json.dumps(info).encode())

114
src/jmclient/yieldgenerator.py

@ -1,5 +1,6 @@
#! /usr/bin/env python
import asyncio
import datetime
import os
import time
@ -7,19 +8,24 @@ import abc
import base64
from twisted.python.log import startLogging
from twisted.application.service import Service
from twisted.internet import task
from twisted.internet import task, defer
from optparse import OptionParser
from jmbase import get_log
from jmclient import (Maker, jm_single, load_program_config,
JMClientProtocolFactory, start_reactor, calc_cj_fee,
WalletService, add_base_options, SNICKERReceiver,
SNICKERClientProtocolFactory, FidelityBondMixin,
get_interest_rate, fmt_utxo, check_and_start_tor)
get_interest_rate, fmt_utxo, check_and_start_tor,
FrostWallet)
from .wallet_utils import open_test_wallet_maybe, get_wallet_path
from jmbase.support import EXIT_ARGERROR, EXIT_FAILURE, get_jm_version_str
from jmbase.support import (EXIT_ARGERROR, EXIT_FAILURE, get_jm_version_str,
twisted_sys_exit)
import jmbitcoin as btc
from jmclient.fidelity_bond import FidelityBond
from .frost_ipc import FrostIPCClient
jlog = get_log()
MAX_MIX_DEPTH = 5
@ -99,13 +105,15 @@ class YieldGeneratorBasic(YieldGenerator):
max_mix = max(mix_balance, key=mix_balance.get)
f = '0'
if self.ordertype in ('reloffer', 'swreloffer', 'sw0reloffer'):
if self.ordertype in ('reloffer', 'swreloffer',
'sw0reloffer', 'trreloffer'):
f = self.cjfee_r
#minimum size bumped if necessary such that you always profit
#least 50% of the miner fee
self.minsize = max(int(1.5 * self.txfee_contribution /
float(self.cjfee_r)), self.minsize)
elif self.ordertype in ('absoffer', 'swabsoffer', 'sw0absoffer'):
elif self.ordertype in ('absoffer', 'swabsoffer',
'sw0absoffer', 'trreloffer'):
f = str(self.txfee_contribution + self.cjfee_a)
order = {'oid': 0,
'ordertype': self.ordertype,
@ -125,7 +133,7 @@ class YieldGeneratorBasic(YieldGenerator):
return [order]
def get_fidelity_bond_template(self):
async def get_fidelity_bond_template(self):
if not isinstance(self.wallet_service.wallet, FidelityBondMixin):
jlog.info("Not a fidelity bond wallet, not announcing fidelity bond")
return None
@ -138,8 +146,10 @@ class YieldGeneratorBasic(YieldGenerator):
CERT_MAX_VALIDITY_TIME = 1
cert_expiry = ((blocks + BLOCK_COUNT_SAFETY) // RETARGET_INTERVAL) + CERT_MAX_VALIDITY_TIME
utxos = self.wallet_service.wallet.get_utxos_by_mixdepth(include_disabled=True,
includeheight=True)[FidelityBondMixin.FIDELITY_BOND_MIXDEPTH]
_utxos = await self.wallet_service.wallet.get_utxos_by_mixdepth(
include_disabled=True,
includeheight=True)
utxos = _utxos[FidelityBondMixin.FIDELITY_BOND_MIXDEPTH]
timelocked_utxos = [(outpoint, info) for outpoint, info in utxos.items()
if FidelityBondMixin.is_timelocked_path(info["path"])]
if len(timelocked_utxos) == 0:
@ -169,7 +179,7 @@ class YieldGeneratorBasic(YieldGenerator):
jlog.info("Announcing fidelity bond coin {}".format(fmt_utxo(utxo)))
return fidelity_bond
def oid_to_order(self, offer, amount):
async def oid_to_order(self, offer, amount):
total_amount = amount + offer["txfee"]
real_cjfee = calc_cj_fee(offer["ordertype"], offer["cjfee"], amount)
required_amount = total_amount + \
@ -184,7 +194,7 @@ class YieldGeneratorBasic(YieldGenerator):
jlog.debug('mix depths that have enough = ' + str(filtered_mix_balance))
try:
mixdepth, utxos = self._get_order_inputs(
mixdepth, utxos = await self._get_order_inputs(
filtered_mix_balance, offer, required_amount)
except NoIoauthInputException:
jlog.error(
@ -196,16 +206,17 @@ class YieldGeneratorBasic(YieldGenerator):
jlog.info('filling offer, mixdepth=' + str(mixdepth) + ', amount=' + str(amount))
cj_addr = self.select_output_address(mixdepth, offer, amount)
cj_addr = await self.select_output_address(mixdepth, offer, amount)
if cj_addr is None:
return None, None, None
jlog.info('sending output to address=' + str(cj_addr))
change_amount = sum(u["value"] for u in utxos.values()) - total_amount + real_cjfee
change_addr = self.select_change_address(mixdepth, change_amount)
change_addr = await self.select_change_address(mixdepth, change_amount)
return utxos, cj_addr, change_addr
def _get_order_inputs(self, filtered_mix_balance, offer, required_amount):
async def _get_order_inputs(self, filtered_mix_balance,
offer, required_amount):
"""
Select inputs from some applicable mixdepth that has a utxo suitable
for ioauth.
@ -224,7 +235,7 @@ class YieldGeneratorBasic(YieldGenerator):
while filtered_mix_balance:
mixdepth = self.select_input_mixdepth(
filtered_mix_balance, offer, required_amount)
utxos = self.wallet_service.select_utxos(
utxos = await self.wallet_service.select_utxos(
mixdepth, required_amount, minconfs=1, includeaddr=True,
require_auth_address=True)
if utxos:
@ -261,21 +272,24 @@ class YieldGeneratorBasic(YieldGenerator):
available = sorted(available.items(), key=lambda entry: entry[0])
return available[0][0]
def select_output_address(self, input_mixdepth, offer, amount):
async def select_output_address(self, input_mixdepth, offer, amount):
"""Returns the address to which the mixed output should be sent for
an order spending from the given input mixdepth. Can return None if
there is no suitable output, in which case the order is
aborted."""
cjoutmix = (input_mixdepth + 1) % (self.wallet_service.mixdepth + 1)
return self.wallet_service.get_internal_addr(cjoutmix)
return await self.wallet_service.get_internal_addr(cjoutmix)
def select_change_address(self, input_mixdepth: int, change_amount: int) -> str:
async def select_change_address(self, input_mixdepth: int,
change_amount: int) -> str:
"""Returns the address to which the change should be sent for an
order spending from the given input mixdepth. Must not return
None."""
return self.wallet_service.get_internal_addr(input_mixdepth)
return await self.wallet_service.get_internal_addr(input_mixdepth)
class YieldGeneratorService(Service):
def __init__(self, wallet_service, daemon_host, daemon_port, yg_config):
self.wallet_service = wallet_service
self.daemon_host = daemon_host
@ -287,6 +301,7 @@ class YieldGeneratorService(Service):
self.setup_fns = []
self.cleanup_fns = []
@defer.inlineCallbacks
def startService(self):
""" We instantiate the Maker class only
here as its constructor will automatically
@ -296,10 +311,15 @@ class YieldGeneratorService(Service):
no need to check this here.
"""
for setup in self.setup_fns:
# we do not catch Exceptions in setup,
# deliberately; this must be caught and distinguished
# exceptions returned from startService
# this must be caught and distinguished
# by whoever started the service.
setup()
try:
setup_res = setup()
if asyncio.iscoroutine(setup_res):
yield defer.Deferred.fromCoroutine(setup_res)
except Exception as e:
return e
# TODO genericise to any YG class:
self.yieldgen = YieldGeneratorBasic(self.wallet_service, self.yg_config)
@ -335,6 +355,7 @@ class YieldGeneratorService(Service):
"""
self.cleanup_fns.append(cleanup)
@defer.inlineCallbacks
def stopService(self):
""" TODO need a method exposed to gracefully
shut down a maker bot.
@ -344,12 +365,19 @@ class YieldGeneratorService(Service):
self.clientfactory.proto_client.request_mc_shutdown()
super().stopService()
for cleanup in self.cleanup_fns:
cleanup()
try:
cleanup_res = cleanup()
if asyncio.iscoroutine(cleanup_res):
yield defer.Deferred.fromCoroutine(cleanup_res)
except Exception as e:
str_e = str(e)
err_msg = f'error {str_e}' if str_e else 'error'
jlog.warn(f'stopService cleanup_fn {cleanup} {err_msg}')
def isRunning(self):
return self.running == 1
def ygmain(ygclass, nickserv_password='', gaplimit=6):
async def ygmain(ygclass, nickserv_password='', gaplimit=6):
import sys
parser = OptionParser(usage='usage: %prog [options] [wallet file]')
@ -404,7 +432,7 @@ def ygmain(ygclass, nickserv_password='', gaplimit=6):
options = vars(options)
if len(args) < 1:
parser.error('Needs a wallet')
sys.exit(EXIT_ARGERROR)
twisted_sys_exit(EXIT_ARGERROR)
load_program_config(config_path=options["datadir"])
@ -435,27 +463,30 @@ def ygmain(ygclass, nickserv_password='', gaplimit=6):
else:
parser.error('You specified an incorrect offer type which ' +\
'can be either reloffer or absoffer')
sys.exit(EXIT_ARGERROR)
twisted_sys_exit(EXIT_ARGERROR)
nickserv_password = options["password"]
if jm_single().bc_interface is None:
jlog.error("Running yield generator requires configured " +
"blockchain source.")
sys.exit(EXIT_FAILURE)
twisted_sys_exit(EXIT_FAILURE)
wallet_path = get_wallet_path(wallet_name, None)
wallet = open_test_wallet_maybe(
wallet = await open_test_wallet_maybe(
wallet_path, wallet_name, options["mixdepth"],
wallet_password_stdin=options["wallet_password_stdin"],
gap_limit=options["gaplimit"])
if isinstance(wallet, FrostWallet):
ipc_client = FrostIPCClient(wallet)
await ipc_client.async_init()
wallet.set_ipc_client(ipc_client)
wallet_service = WalletService(wallet)
while not wallet_service.synced:
wallet_service.sync_wallet(fast=not options["recoversync"])
wallet_service.startService()
txtype = wallet_service.get_txtype()
if txtype == "p2wpkh":
if txtype == "p2tr":
prefix = "tr"
elif txtype == "p2wpkh":
prefix = "sw0"
elif txtype == "p2sh-p2wpkh":
prefix = "sw"
@ -463,7 +494,7 @@ def ygmain(ygclass, nickserv_password='', gaplimit=6):
prefix = ""
else:
jlog.error("Unsupported wallet type for yieldgenerator: " + txtype)
sys.exit(EXIT_ARGERROR)
twisted_sys_exit(EXIT_ARGERROR)
ordertype = prefix + ordertype
jlog.debug("Set the offer type string to: " + ordertype)
@ -477,9 +508,9 @@ def ygmain(ygclass, nickserv_password='', gaplimit=6):
if jm_single().config.get("BLOCKCHAIN", "network") == "mainnet":
jlog.error("You have enabled SNICKER on mainnet, this is not "
"yet supported for yieldgenerators; either use "
"signet/regtest/testnet, or run SNICKER manually "
"with snicker/receive-snicker.py.")
sys.exit(EXIT_ARGERROR)
"signet/regtest/testnet/testnet4, or run SNICKER "
"manually with snicker/receive-snicker.py.")
twisted_sys_exit(EXIT_ARGERROR)
snicker_r = SNICKERReceiver(wallet_service)
servers = jm_single().config.get("SNICKER", "servers").split(",")
snicker_factory = SNICKERClientProtocolFactory(snicker_r, servers)
@ -487,9 +518,14 @@ def ygmain(ygclass, nickserv_password='', gaplimit=6):
snicker_factory = None
nodaemon = jm_single().config.getint("DAEMON", "no_daemon")
daemon = True if nodaemon == 1 else False
if jm_single().config.get("BLOCKCHAIN", "network") in ["regtest", "testnet", "signet"]:
if jm_single().config.get("BLOCKCHAIN", "network") in [
"regtest", "testnet", "signet", "testnet4"]:
startLogging(sys.stdout)
start_reactor(jm_single().config.get("DAEMON", "daemon_host"),
jm_single().config.getint("DAEMON", "daemon_port"),
clientfactory, snickerfactory=snicker_factory,
daemon=daemon)
jm_single().config.getint("DAEMON", "daemon_port"),
clientfactory, snickerfactory=snicker_factory,
daemon=daemon, gui=True)
while not wallet_service.synced:
await wallet_service.sync_wallet(fast=not options["recoversync"])
wallet_service.startService()

2
src/jmdaemon/__init__.py

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
import logging
from .protocol import *
from .enc_wrapper import as_init_encryption, decode_decrypt, \
encrypt_encode, init_keypair, init_pubkey, get_pubkey, NaclError

313
src/jmdaemon/daemon_protocol.py

@ -26,9 +26,11 @@ from twisted.web import server
from txtorcon.socks import HostUnreachableError
from twisted.python import log
import urllib.parse as urlparse
from collections import defaultdict
from urllib.parse import urlencode
import json
import threading
import time
import os
from io import BytesIO
import copy
@ -485,6 +487,32 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch):
self.use_fidelity_bond = False
self.offerlist = None
self.kp = None
self.frost_crypto_boxes = {}
self.frost_expected_msgs = defaultdict(lambda: defaultdict(dict))
self.frost_cleanup_loop = task.LoopingCall(self.frost_cleanup)
def frost_cleanup(self):
now = time.time()
boxes = self.frost_crypto_boxes
cleanup_list = []
for nick, sessions in boxes.items():
for session_id, box in sessions.items():
if now - box['created'] > 120:
cleanup_list.append((nick, session_id))
for nick, session_id in cleanup_list:
boxes[nick].pop(session_id)
if not boxes[nick]:
boxes.pop(nick)
msgs = self.frost_expected_msgs
cleanup_list = []
for nick, cmds in msgs.items():
for cmd, cmd_data in cmds.items():
if now - cmd_data['created'] > 120:
cleanup_list.append((nick, cmd))
for nick, cmd in cleanup_list:
msgs[nick].pop(cmd)
if not msgs[nick]:
msgs.pop(nick)
def checkClientResponse(self, response):
"""A generic check of client acceptance; any failure
@ -549,6 +577,18 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch):
self.on_push_tx,
self.on_commitment_seen,
self.on_commitment_transferred)
self.mcc.register_frost_callbacks(self.on_dkginit,
self.on_dkgpmsg1,
self.on_dkgpmsg2,
self.on_dkgfinalized,
self.on_dkgcmsg1,
self.on_dkgcmsg2,
self.on_frostreq,
self.on_frostack,
self.on_frostinit,
self.on_frostround1,
self.on_frostround2,
self.on_frostagg1)
self.mcc.set_daemon(self)
d = self.callRemote(JMInitProto,
nick_hash_length=NICK_HASH_LENGTH,
@ -572,7 +612,7 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch):
assert self.jm_state == 0
self.role = role
self.crypto_boxes = {}
self.kp = init_keypair()
self.kp = init_keypair() # FIXME not used by maker, mv to taker code?
d = self.callRemote(JMSetupDone)
self.defaultCallbacks(d)
#Request orderbook here, on explicit setup request from client,
@ -607,6 +647,158 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch):
self.jm_state = 0
return {'accepted': True}
"""DKG specific responders
"""
@JMDKGInit.responder
def on_JM_DKG_INIT(self, hostpubkeyhash, session_id, sig):
self.mcc.pubmsg(f'!dkginit {hostpubkeyhash} {session_id} {sig}')
return {'accepted': True}
@JMDKGPMsg1.responder
def on_JM_DKG_PMSG1(self, nick, hostpubkeyhash, session_id, sig, pmsg1):
msg = f'{hostpubkeyhash} {session_id} {sig} {pmsg1}'
self.mcc.prepare_privmsg(nick, "dkgpmsg1", msg)
return {'accepted': True}
@JMDKGPMsg2.responder
def on_JM_DKG_PMSG2(self, nick, session_id, pmsg2):
msg = f'{session_id} {pmsg2}'
self.mcc.prepare_privmsg(nick, "dkgpmsg2", msg)
return {'accepted': True}
@JMDKGFinalized.responder
def on_JM_DKG_FINALIZED(self, session_id, nick):
msg = f'{session_id}'
self.mcc.prepare_privmsg(nick, "dkgfinalized", msg)
return {'accepted': True}
@JMDKGCMsg1.responder
def on_JM_DKG_CMSG1(self, nick, session_id, cmsg1):
msg = f'{session_id} {cmsg1}'
self.mcc.prepare_privmsg(nick, "dkgcmsg1", msg)
return {'accepted': True}
@JMDKGCMsg2.responder
def on_JM_DKG_CMSG2(self, nick, session_id, cmsg2, ext_recovery):
msg = f'{session_id} {cmsg2} {ext_recovery}'
self.mcc.prepare_privmsg(nick, "dkgcmsg2", msg)
return {'accepted': True}
"""FROST specific responders
"""
@JMFROSTReq.responder
def on_JM_FROST_REQ(self, hostpubkeyhash, sig, session_id):
if not self.frost_cleanup_loop.running:
self.frost_cleanup_loop.start(30.0)
boxes = self.frost_crypto_boxes
nick_boxes = boxes.get(None, {}) # None for self
session_box = nick_boxes.get(session_id, {})
if session_box:
log.msg(f'on_JM_FROST_REQ: session_id "{session_id}" '
f'setup is incorrect. '
f'FROST request aborted.')
return {'accepted': True}
kp = init_keypair()
session_box['kp'] = kp
session_box['created'] = time.time()
nick_boxes[session_id] = session_box
boxes[None] = nick_boxes # None for self
dh_pubk = kp.hex_pk().decode('ascii')
req_msg = f'!frostreq {hostpubkeyhash} {sig} {session_id} {dh_pubk}'
self.mcc.pubmsg(req_msg)
return {'accepted': True}
@JMFROSTAck.responder
def on_JM_FROST_ACK(self, nick, hostpubkeyhash, sig, session_id):
boxes = self.frost_crypto_boxes
self_boxes = boxes.get(None, {}) # None for self
session_box = self_boxes.get(session_id, {})
if session_box:
log.msg(f'on_JM_FROST_ACK: session_id "{session_id}" '
f'setup is incorrect. '
f'FROST request aborted.')
return {'accepted': True}
kp = init_keypair()
session_box['kp'] = kp
session_box['created'] = time.time()
self_boxes[session_id] = session_box
boxes[None] = self_boxes # None for self
nick_boxes = boxes.get(nick, {})
nick_session_box = nick_boxes.get(session_id, {})
if not nick_session_box:
log.msg(f'on_JM_FROST_ACK: nick {nick}, session_id "{session_id}" '
f'setup is incorrect. '
f'FROST request aborted.')
return {'accepted': True}
try:
nick_dh_pubk = nick_session_box['dh_pubk']
nick_session_box['crypto_box'] = as_init_encryption(
kp, init_pubkey(nick_dh_pubk))
except NaclError as e:
log.msg('on frostround1: error creating crypto_box. '
'FROST session aborted')
return {'accepted': True}
self.frost_expected_msgs[nick]['frostinit'] = {
'session_id': session_id,
'created': time.time(),
}
dh_pubk = kp.hex_pk().decode('ascii')
ack_msg = f'{hostpubkeyhash} {sig} {session_id} {dh_pubk}'
self.mcc.prepare_privmsg(nick, 'frostack', ack_msg)
return {'accepted': True}
@JMFROSTInit.responder
def on_JM_FROST_INIT(self, nick, session_id):
self.frost_expected_msgs[nick]['frostround1'] = {
'session_id': session_id,
'created': time.time(),
}
init_msg = f'{session_id}'
self.mcc.prepare_privmsg(nick, 'frostinit', init_msg)
return {'accepted': True}
@JMFROSTRound1.responder
def on_JM_FROST_ROUND1(self, nick, session_id, pub_nonce):
self.frost_expected_msgs[nick]['frostagg1'] = {
'session_id': session_id,
'created': time.time(),
}
round1_msg = f'{session_id} {pub_nonce}'
self.mcc.prepare_privmsg(nick, "frostround1", round1_msg)
return {'accepted': True}
@JMFROSTAgg1.responder
def on_JM_FROST_AGG1(self, nick, session_id,
nonce_agg, dkg_session_id, ids, msg):
self.frost_expected_msgs[nick]['frostround2'] = {
'session_id': session_id,
'created': time.time(),
}
agg1_msg = f'{session_id} {nonce_agg} {dkg_session_id} {ids} {msg}'
self.mcc.prepare_privmsg(nick, "frostagg1", agg1_msg)
return {'accepted': True}
@JMFROSTRound2.responder
def on_JM_FROST_ROUND2(self, nick, session_id, partial_sig):
msg = f'{session_id} {partial_sig}'
self.mcc.prepare_privmsg(nick, "frostround2", msg)
# cleanup frost_crypto_boxes
boxes = self.frost_crypto_boxes
cleanup_list = []
for nick, sessions in boxes.items():
if session_id in sessions:
cleanup_list.append((nick, session_id))
for nick, session_id in cleanup_list:
boxes[nick].pop(session_id)
if not boxes[nick]:
boxes.pop(nick)
return {'accepted': True}
"""Taker specific responders
"""
@ -737,6 +929,121 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch):
d = self.callRemote(JMUp)
self.defaultCallbacks(d)
# frost commands
def on_dkginit(self, nick, hostpubkeyhash, session_id, sig):
d = self.callRemote(JMDKGInitSeen,
nick=nick, hostpubkeyhash=hostpubkeyhash,
session_id=session_id, sig=sig)
self.defaultCallbacks(d)
def on_dkgpmsg1(self, nick, hostpubkeyhash, session_id, sig, pmsg1):
d = self.callRemote(JMDKGPMsg1Seen,
nick=nick, hostpubkeyhash=hostpubkeyhash,
session_id=session_id, sig=sig, pmsg1=pmsg1)
self.defaultCallbacks(d)
def on_dkgpmsg2(self, nick, session_id, pmsg2):
d = self.callRemote(JMDKGPMsg2Seen,
nick=nick, session_id=session_id, pmsg2=pmsg2)
self.defaultCallbacks(d)
def on_dkgfinalized(self, nick, session_id):
d = self.callRemote(JMDKGFinalizedSeen,
nick=nick, session_id=session_id)
self.defaultCallbacks(d)
def on_dkgcmsg1(self, nick, session_id, cmsg1):
d = self.callRemote(JMDKGCMsg1Seen,
nick=nick, session_id=session_id, cmsg1=cmsg1)
self.defaultCallbacks(d)
def on_dkgcmsg2(self, nick, session_id, cmsg2, ext_recovery):
d = self.callRemote(JMDKGCMsg2Seen,
nick=nick, session_id=session_id, cmsg2=cmsg2,
ext_recovery=ext_recovery)
self.defaultCallbacks(d)
def on_frostreq(self, nick, hostpubkeyhash, sig, session_id, dh_pubk):
boxes = self.frost_crypto_boxes
nick_boxes = boxes.get(nick, {})
session_box = nick_boxes.get(session_id, {})
if not session_box and not 'dh_pubk' in session_box:
session_box['dh_pubk'] = dh_pubk
session_box['created'] = time.time()
nick_boxes[session_id] = session_box
boxes[nick] = nick_boxes
d = self.callRemote(JMFROSTReqSeen,
nick=nick, hostpubkeyhash=hostpubkeyhash,
sig=sig, session_id=session_id)
self.defaultCallbacks(d)
def on_frostack(self, nick, hostpubkeyhash, sig, session_id, dh_pubk):
boxes = self.frost_crypto_boxes
nick_boxes = boxes.get(nick, {})
session_box = nick_boxes.get(session_id, {})
if not session_box and not 'dh_pubk' in session_box:
session_box['dh_pubk'] = dh_pubk
session_box['created'] = time.time()
nick_boxes[session_id] = session_box
boxes[nick] = nick_boxes
self_boxes = boxes.get(None, {})
self_session_box = self_boxes.get(session_id, {})
if not self_session_box or not 'kp' in self_session_box:
log.msg(f'on_frostack: session_id "{session_id}" '
f' setup is incorrect. '
f'FROST session aborted.')
return
try:
kp = self_session_box['kp']
session_box['crypto_box'] = as_init_encryption(
kp, init_pubkey(dh_pubk))
except NaclError as e:
log.msg('on frostround1: error creating crypto_box. '
'FROST session aborted')
return
d = self.callRemote(JMFROSTAckSeen,
nick=nick, hostpubkeyhash=hostpubkeyhash,
sig=sig, session_id=session_id)
self.defaultCallbacks(d)
def on_frostinit(self, nick, session_id):
d = self.callRemote(JMFROSTInitSeen,
nick=nick, session_id=session_id)
self.defaultCallbacks(d)
def on_frostround1(self, nick, session_id, pub_nonce):
d = self.callRemote(JMFROSTRound1Seen,
nick=nick, session_id=session_id,
pub_nonce=pub_nonce)
self.defaultCallbacks(d)
def on_frostround2(self, nick, session_id, partial_sig):
d = self.callRemote(JMFROSTRound2Seen,
nick=nick, session_id=session_id,
partial_sig=partial_sig)
self.defaultCallbacks(d)
# cleanup frost_crypto_boxes
boxes = self.frost_crypto_boxes
cleanup_list = []
for nick, sessions in boxes.items():
if session_id in sessions:
cleanup_list.append((nick, session_id))
for nick, session_id in cleanup_list:
boxes[nick].pop(session_id)
if not boxes[nick]:
boxes.pop(nick)
def on_frostagg1(self, nick, session_id,
nonce_agg, dkg_session_id, ids, msg):
d = self.callRemote(JMFROSTAgg1Seen,
nick=nick, session_id=session_id,
nonce_agg=nonce_agg,
dkg_session_id=dkg_session_id, ids=ids, msg=msg)
self.defaultCallbacks(d)
@maker_only
def on_orderbook_requested(self, nick, mc=None):
"""Dealt with by daemon, assuming offerlist is up to date
@ -1030,7 +1337,9 @@ class JMDaemonServerProtocol(amp.AMP, OrderbookWatch):
"""Retrieve the libsodium box object for the counterparty;
stored differently for Taker and Maker
"""
if nick in self.crypto_boxes and self.crypto_boxes[nick] != None:
if nick in self.frost_crypto_boxes:
return self.frost_crypto_boxes[nick]
elif nick in self.crypto_boxes and self.crypto_boxes[nick] != None:
return self.crypto_boxes[nick][1]
elif nick in self.active_orders and self.active_orders[nick] != None \
and "crypto_box" in self.active_orders[nick]:

4
src/jmdaemon/irc.py

@ -26,7 +26,7 @@ def wlog(*x):
if x[0] == "WARNING":
msg = " ".join([conv(a) for a in x[1:]])
log.warn(msg)
log.warning(msg)
elif x[0] == "INFO":
msg = " ".join([conv(a) for a in x[1:]])
log.info(msg)
@ -47,6 +47,8 @@ def get_config_irc_channel(chan_name, btcnet):
channel = "#" + chan_name
if btcnet == "testnet":
channel += "-test"
elif btcnet == "testnet4":
channel += "-test4"
elif btcnet == "signet":
channel += "-sig"
return channel

199
src/jmdaemon/message_channel.py

@ -82,8 +82,8 @@ class MessageChannelCollection(object):
#but should not kill the bot. So, we don't raise an
#exception, but rather allow sending to continue, which
#should usually result in tx completion just timing out.
log.warn("Couldn't find a route to send privmsg")
log.warn("For counterparty: " + str(cp))
log.warning("Couldn't find a route to send privmsg")
log.warning("For counterparty: " + str(cp))
return func_wrapper
@ -207,7 +207,7 @@ class MessageChannelCollection(object):
#END PUBLIC/BROADCAST SECTION
def get_encryption_box(self, cmd, nick):
def get_encryption_box(self, cmd, nick, extra=None):
"""Establish whether the message is to be
encrypted/decrypted based on the command string.
If so, retrieve the appropriate crypto_box object
@ -215,12 +215,25 @@ class MessageChannelCollection(object):
if cmd in plaintext_commands:
return None, False
else:
return self.daemon.get_crypto_box_from_nick(nick), True
if cmd in ['frostinit', 'frostround1', 'frostagg1', 'frostround2']:
boxes = self.daemon.get_crypto_box_from_nick(nick)
if boxes:
if extra:
box = boxes.get(extra)['crypto_box']
else:
box = None
else:
box = self.daemon.get_crypto_box_from_nick(nick)
return box, True
@check_privmsg
def prepare_privmsg(self, nick, cmd, message, mc=None):
# should we encrypt?
box, encrypt = self.get_encryption_box(cmd, nick)
if cmd in ['frostinit', 'frostround1', 'frostagg1', 'frostround2']:
session_id = message.split()[0]
box, encrypt = self.get_encryption_box(cmd, nick, extra=session_id)
else:
box, encrypt = self.get_encryption_box(cmd, nick)
if encrypt:
if not box:
log.debug('error, dont have encryption box object for ' + nick +
@ -609,6 +622,28 @@ class MessageChannelCollection(object):
on_order_fill, on_seen_auth, on_seen_tx,
on_push_tx, on_commitment_seen,
on_commitment_transferred)
# frost commands
def register_frost_callbacks(self,
on_dkginit=None,
on_dkgpmsg1=None,
on_dkgpmsg2=None,
on_dkgfinalized=None,
on_dkgcmsg1=None,
on_dkgcmsg2=None,
on_frostreq=None,
on_frostack=None,
on_frostinit=None,
on_frostround1=None,
on_frostround2=None,
on_frostagg1=None):
for mc in self.mchannels:
mc.register_frost_callbacks(
on_dkginit,
on_dkgpmsg1, on_dkgpmsg2, on_dkgfinalized,
on_dkgcmsg1, on_dkgcmsg2,
on_frostreq, on_frostack,
on_frostinit, on_frostround1,
on_frostround2, on_frostagg1)
def on_verified_privmsg(self, nick, message, hostid):
"""Called from daemon when message was successfully verified,
@ -616,8 +651,8 @@ class MessageChannelCollection(object):
"""
matched_channels = [x for x in self.mchannels if hostid == x.hostid]
if len(matched_channels) != 1:
log.warn("Channel on which privmsg was received is now inactive; "
"continuing to process this message")
log.warning("Channel on which privmsg was received is now"
" inactive; continuing to process this message")
mc = matched_channels[0]
mc.on_verified_privmsg(nick, message)
@ -666,6 +701,19 @@ class MessageChannel(object):
self.on_seen_auth = None
self.on_seen_tx = None
self.on_push_tx = None
# frost functions
self.on_dkginit = None
self.on_dkgpmsg1 = None
self.on_dkgpmsg2 = None
self.on_dkgfinalized = None
self.on_dkgcmsg1 = None
self.on_dkgcmsg2 = None
self.on_frostreq = None
self.on_frostack = None
self.on_frostinit = None
self.on_frostround1 = None
self.on_frostround2 = None
self.on_frostagg1 = None
self.daemon = None
@ -772,6 +820,33 @@ class MessageChannel(object):
self.on_commitment_seen = on_commitment_seen
self.on_commitment_transferred = on_commitment_transferred
# frost commands
def register_frost_callbacks(self,
on_dkginit=None,
on_dkgpmsg1=None,
on_dkgpmsg2=None,
on_dkgfinalized=None,
on_dkgcmsg1=None,
on_dkgcmsg2=None,
on_frostreq=None,
on_frostack=None,
on_frostinit=None,
on_frostround1=None,
on_frostround2=None,
on_frostagg1=None):
self.on_dkginit = on_dkginit
self.on_dkgpmsg1 = on_dkgpmsg1
self.on_dkgpmsg2 = on_dkgpmsg2
self.on_dkgfinalized = on_dkgfinalized
self.on_dkgcmsg1 = on_dkgcmsg1
self.on_dkgcmsg2 = on_dkgcmsg2
self.on_frostreq = on_frostreq
self.on_frostack = on_frostack
self.on_frostinit = on_frostinit
self.on_frostround1 = on_frostround1
self.on_frostround2 = on_frostround2
self.on_frostagg1 = on_frostagg1
def announce_orders(self, orderlines):
self._announce_orders(orderlines)
@ -889,7 +964,29 @@ class MessageChannel(object):
return
for command in commands:
_chunks = command.split(" ")
if self.check_for_orders(nick, _chunks):
if _chunks[0] == 'dkginit':
try:
hostpubkeyhash = _chunks[1]
session_id = _chunks[2]
sig = _chunks[3]
if self.on_dkginit:
self.on_dkginit(nick, hostpubkeyhash, session_id, sig)
except (ValueError, IndexError) as e:
log.debug("!dkginit" + repr(e))
return
elif _chunks[0] == 'frostreq':
try:
hostpubkeyhash = _chunks[1]
sig = _chunks[2]
session_id = _chunks[3]
dh_pubk = _chunks[4]
if self.on_frostreq:
self.on_frostreq(
nick, hostpubkeyhash, sig, session_id, dh_pubk)
except (ValueError, IndexError) as e:
log.debug("!frostreq" + repr(e))
return
elif self.check_for_orders(nick, _chunks):
pass
if self.check_for_commitments(nick, _chunks):
pass
@ -964,9 +1061,27 @@ class MessageChannel(object):
_chunks = command.split(" ")
#Decrypt if necessary
if _chunks[0] in encrypted_commands:
box, encrypt = self.daemon.mcc.get_encryption_box(_chunks[0],
nick)
cmd = _chunks[0]
if cmd in encrypted_commands:
if cmd in ['frostinit', 'frostround1',
'frostagg1', 'frostround2']:
expected_msgs = self.daemon.frost_expected_msgs
if nick in expected_msgs:
if cmd in expected_msgs[nick]:
expected_msg = expected_msgs[nick].pop(cmd)
if not expected_msgs[nick]:
expected_msgs.pop(nick)
if expected_msg:
session_id = expected_msg['session_id']
box, encrypt = \
self.daemon.mcc.get_encryption_box(
cmd, nick, extra=session_id)
else:
box = None
encrypt = True
else:
box, encrypt = self.daemon.mcc.get_encryption_box(
cmd, nick)
if encrypt:
if not box:
log.debug('error, dont have encryption box object for '
@ -1057,6 +1172,68 @@ class MessageChannel(object):
return
if self.on_push_tx:
self.on_push_tx(nick, tx)
# frost commands
elif _chunks[0] == 'dkgpmsg1':
hostpubkeyhash = _chunks[1]
session_id = _chunks[2]
sig = _chunks[3]
pmsg1 = _chunks[4]
if self.on_dkgpmsg1:
self.on_dkgpmsg1(nick, hostpubkeyhash, session_id, sig,
pmsg1)
elif _chunks[0] == 'dkgpmsg2':
session_id = _chunks[1]
pmsg2 = _chunks[2]
if self.on_dkgpmsg2:
self.on_dkgpmsg2(nick, session_id, pmsg2)
elif _chunks[0] == 'dkgfinalized':
session_id = _chunks[1]
if self.on_dkgfinalized:
self.on_dkgfinalized(nick, session_id)
elif _chunks[0] == 'dkgcmsg1':
session_id = _chunks[1]
cmsg1 = _chunks[2]
if self.on_dkgcmsg1:
self.on_dkgcmsg1(nick, session_id, cmsg1)
elif _chunks[0] == 'dkgcmsg2':
session_id = _chunks[1]
cmsg2 = _chunks[2]
ext_recovery = _chunks[3]
if self.on_dkgcmsg2:
self.on_dkgcmsg2(nick, session_id, cmsg2, ext_recovery)
elif _chunks[0] == 'frostack':
hostpubkeyhash = _chunks[1]
sig = _chunks[2]
session_id = _chunks[3]
dh_pubk = _chunks[4]
if self.on_frostack:
self.on_frostack(
nick, hostpubkeyhash, sig, session_id, dh_pubk)
elif _chunks[0] == 'frostinit':
session_id = _chunks[1]
if self.on_frostinit:
self.on_frostinit(nick, session_id)
elif _chunks[0] == 'frostround1':
session_id = _chunks[1]
pub_nonce = _chunks[2]
if self.on_frostround1:
self.on_frostround1(nick, session_id, pub_nonce)
elif _chunks[0] == 'frostagg1':
session_id = _chunks[1]
nonce_agg = _chunks[2]
dkg_session_id = _chunks[3]
ids = _chunks[4]
msg = _chunks[5]
if self.on_frostagg1:
self.on_frostagg1(
nick, session_id, nonce_agg,
dkg_session_id, ids, msg)
elif _chunks[0] == 'frostround2':
session_id = _chunks[1]
partial_sig = _chunks[2]
if self.on_frostround2:
self.on_frostround2(nick, session_id, partial_sig)
except (IndexError, ValueError):
# TODO proper error handling
log.debug('cj peer error TODO handle')

85
src/jmdaemon/onionmc.py

@ -206,14 +206,15 @@ class OnionLineProtocolFactory(protocol.ServerFactory):
peer_location = network_addr_to_string(p.transport.getPeer())
self.client.register_disconnection(peer_location)
if peer_location not in self.peers:
log.warn("Disconnection event registered for non-existent peer.")
log.warning("Disconnection event registered for"
" non-existent peer.")
return
del self.peers[peer_location]
def disconnect_inbound_peer(self, inbound_peer_str: str) -> None:
if inbound_peer_str not in self.peers:
log.warn("cannot disconnect peer at {}, not found".format(
inbound_peer_str))
log.warning("cannot disconnect peer at {}, not"
" found".format(inbound_peer_str))
proto = self.peers[inbound_peer_str]
proto.transport.loseConnection()
@ -224,8 +225,9 @@ class OnionLineProtocolFactory(protocol.ServerFactory):
def send(self, message: OnionCustomMessage, destination: str) -> bool:
if destination not in self.peers:
log.warn("sending message {}, destination {} was not in peers {}".format(
message.encode(), destination, self.peers))
log.warning(
"sending message {}, destination {} was not in peers"
" {}".format(message.encode(), destination, self.peers))
return False
proto = self.peers[destination]
proto.message(message)
@ -479,8 +481,9 @@ class OnionPeer(object):
code; no action is triggered.
"""
name = "directory" if self.directory else "peer"
log.warn("Failure to send message to {}: {}.".format(
name, self.peer_location()))
log.warning(
"Failure to send message to"
" {}: {}.".format(name, self.peer_location()))
def connect(self) -> None:
""" This method is called to connect, over Tor, to the remote
@ -553,8 +556,8 @@ class OnionPeer(object):
# TODO remove message or change it.
log.debug("Tried to connect but failed: {}".format(repr(e)))
except Exception as e:
log.warn("Got unexpected exception in connect attempt: {}".format(
repr(e)))
log.warning("Got unexpected exception in connect"
" attempt: {}".format(repr(e)))
def disconnect(self) -> None:
if self._status in [PEER_STATUS_UNCONNECTED, PEER_STATUS_DISCONNECTED]:
@ -824,8 +827,8 @@ class OnionMessageChannel(MessageChannel):
try:
peer_sendable = self.get_directory_for_nick(nick)
except OnionDirectoryPeerNotFound:
log.warn("Failed to send privmsg because no "
"directory peer is connected.")
log.warning("Failed to send privmsg because no "
"directory peer is connected.")
return
self._send(peer_sendable, encoded_privmsg)
@ -946,8 +949,8 @@ class OnionMessageChannel(MessageChannel):
except Exception as e:
# This can happen when a peer disconnects, depending
# on the timing:
log.warn("Failed to send message to: {}, error: {}".format(
peer.peer_location(), repr(e)))
log.warning("Failed to send message to: {}, error:"
" {}".format(peer.peer_location(), repr(e)))
return False
def receive_msg(self, message: OnionCustomMessage, peer_location: str) -> None:
@ -961,7 +964,8 @@ class OnionMessageChannel(MessageChannel):
log.debug("received message as directory: {}".format(message.encode()))
peer = self.get_peer_by_id(peer_location)
if not peer:
log.warn("Received message but could not find peer: {}".format(peer_location))
log.warning("Received message but could not find peer:"
" {}".format(peer_location))
return
msgtype = message.msgtype
msgval = message.text
@ -1153,7 +1157,8 @@ class OnionMessageChannel(MessageChannel):
# returning True whether raised or not - see docstring
return True
elif msgtype == CONTROL_MESSAGE_TYPES["getpeerlist"]:
log.warn("getpeerlist request received, currently not supported.")
log.warning("getpeerlist request received, currently"
" not supported.")
return True
elif msgtype == CONTROL_MESSAGE_TYPES["handshake"]:
# sent by non-directory peers on startup, also to
@ -1202,15 +1207,15 @@ class OnionMessageChannel(MessageChannel):
peer = self.get_peer_by_id(peerid)
if not peer:
# rando sent us a handshake?
log.warn("Unexpected handshake from unknown peer: {}, "
"ignoring.".format(peerid))
log.warning("Unexpected handshake from unknown peer: {}, "
"ignoring.".format(peerid))
return
assert isinstance(peer, OnionPeer)
if not peer.status() == PEER_STATUS_CONNECTED:
# we were not waiting for it:
log.warn("Unexpected handshake from peer: {}, "
"ignoring. Peer's current status is: {}".format(
peerid, peer.status()))
log.warning(
"Unexpected handshake from peer: {}, ignoring. Peer's current"
" status is: {}".format(peerid, peer.status()))
return
if dn:
# it means, we are a non-dn and we are expecting
@ -1219,8 +1224,8 @@ class OnionMessageChannel(MessageChannel):
assert not self.self_as_peer.directory
if not peer.directory:
# got dn-handshake from non-dn:
log.warn("Unexpected dn-handshake from non-dn "
"node: {}, ignoring.".format(peerid))
log.warning("Unexpected dn-handshake from non-dn "
"node: {}, ignoring.".format(peerid))
return
# we got the right message from the right peer;
# check it is formatted correctly and represents
@ -1241,28 +1246,29 @@ class OnionMessageChannel(MessageChannel):
assert isinstance(nick, str)
assert isinstance(net, str)
except Exception as e:
log.warn("Invalid handshake message from: {},"
" exception: {}, message: {},ignoring".format(
peerid, repr(e), message))
log.warning(
"Invalid handshake message from: {}, exception: {},"
" message: {},ignoring".format(peerid, repr(e), message))
return
# currently we are not using any features, but the intention
# is forwards compatibility, so we don't check its contents
# at all.
if not accepted:
log.warn("Directory: {} rejected our handshake.".format(peerid))
log.warning("Directory: {} rejected our "
"handshake.".format(peerid))
# explicitly choose to disconnect (if other side already did,
# this is no-op).
peer.disconnect()
return
if not (app_name == JM_APP_NAME and is_directory and JM_VERSION \
<= proto_max and JM_VERSION >= proto_min and accepted):
log.warn("Handshake from directory is incompatible or "
"rejected: {}".format(handshake_json))
log.warning("Handshake from directory is incompatible or "
"rejected: {}".format(handshake_json))
peer.disconnect()
return
if not net == self.btc_network:
log.warn("Handshake from directory is on an incompatible "
"network: {}".format(net))
log.warning("Handshake from directory is on an incompatible "
"network: {}".format(net))
return
# We received a valid, accepting dn-handshake. Update the peer.
peer.update_status(PEER_STATUS_HANDSHAKED)
@ -1287,19 +1293,21 @@ class OnionMessageChannel(MessageChannel):
assert isinstance(nick, str)
assert isinstance(net, str)
except Exception as e:
log.warn("(not dn) Invalid handshake message from: {}, "
"exception: {}, message: {}, ignoring".format(
peerid, repr(e), message))
log.warning(
"(not dn) Invalid handshake message from:"
" {}, exception: {}, message: {},"
" ignoring".format(peerid, repr(e), message))
# just ignore, since a syntax failure could lead to a crash
return
if not (app_name == JM_APP_NAME and proto_ver == JM_VERSION \
and not is_directory):
log.warn("Invalid handshake name/version data: {}, from peer: "
"{}, rejecting.".format(message, peerid))
log.warning(
"Invalid handshake name/version data: {},"
" from peer: {}, rejecting.".format(message, peerid))
accepted = False
if not net == self.btc_network:
log.warn("Handshake from peer is on an incompatible "
"network: {}".format(net))
log.warning("Handshake from peer is on an incompatible "
"network: {}".format(net))
accepted = False
# If accepted, we should update the peer to have the full
# location which in general will not yet be present, so as to
@ -1398,7 +1406,8 @@ class OnionMessageChannel(MessageChannel):
# There are currently a few ways the location
# parsing and Peer object construction can fail;
# TODO specify exception types.
log.warn("Failed to add peer: {}, exception: {}".format(peer, repr(e)))
log.warning("Failed to add peer: {}, exception:"
" {}".format(peer, repr(e)))
return
if not self.get_peer_by_id(temp_p.peer_location()):
self.peers.add(temp_p)

5
src/jmdaemon/orderbookwatch.py

@ -96,8 +96,9 @@ class OrderbookWatch(object):
"from {}").format
log.debug(fmt(minsize, maxsize, counterparty))
return
if ordertype in ['sw0absoffer', 'swabsoffer', 'absoffer']\
and not isinstance(cjfee, Integral):
if (ordertype in ['trabsoffer', 'sw0absoffer',
'swabsoffer', 'absoffer']
and not isinstance(cjfee, Integral)):
try:
cjfee = int(cjfee)
except ValueError:

22
src/jmdaemon/protocol.py

@ -16,6 +16,10 @@ offertypes = {"reloffer": [(int, "oid"), (int, "minsize"), (int, "maxsize"),
"sw0reloffer": [(int, "oid"), (int, "minsize"), (int, "maxsize"),
(int, "txfee"), (float, "cjfee")],
"sw0absoffer": [(int, "oid"), (int, "minsize"), (int, "maxsize"),
(int, "txfee"), (int, "cjfee")],
"trreloffer": [(int, "oid"), (int, "minsize"), (int, "maxsize"),
(int, "txfee"), (float, "cjfee")],
"trabsoffer": [(int, "oid"), (int, "minsize"), (int, "maxsize"),
(int, "txfee"), (int, "cjfee")]}
offername_list = list(offertypes.keys())
@ -33,11 +37,25 @@ NICK_MAX_ENCODED = 14 #comes from base58 expansion; recalculate if above change
#commitments; note multiple options may be used in future
COMMITMENT_PREFIXES = ["P"]
#Lists of valid commands
dkg_public_list = ['dkginit']
dkg_private_list = ['dkgpmsg1', 'dkgpmsg2', 'dkgcmsg1', 'dkgcmsg2',
'dkgfinalized']
frost_public_list = ['frostreq']
frost_plaintext_list = frost_public_list + ['frostack']
frost_encrypted_list = ['frostinit', 'frostround1',
'frostround2', 'frostagg1']
encrypted_commands = ["auth", "ioauth", "tx", "sig"]
encrypted_commands += frost_encrypted_list
plaintext_commands = ["fill", "error", "pubkey", "orderbook", "push"]
commitment_broadcast_list = ["hp2"]
plaintext_commands += offername_list
plaintext_commands += commitment_broadcast_list
public_commands = commitment_broadcast_list + ["orderbook", "cancel"
] + offername_list
plaintext_commands += dkg_public_list
plaintext_commands += dkg_private_list
plaintext_commands += frost_plaintext_list
public_commands = commitment_broadcast_list + [
"orderbook", "cancel" ] + offername_list + [
dkg_public_list + frost_public_list]
private_commands = encrypted_commands + plaintext_commands

21
src/jmfrost/__init__.py

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# chilldkg_ref, secp256k1lab code is from
# https://github.com/BlockstreamResearch/bip-frost-dkg
#
# commit 0f9e4b95b2e1ef4a0d335908e512ddaca60ebd99
# Merge: aff38ce 7ddab85
# Author: Jonas Nick <jonasd.nick@gmail.com>
# Date: Thu May 8 07:40:00 2025 +0000
#
# Merge pull request #93 from jonasnick/address-siv-comments
# frost_ref is from
# https://github.com/siv2r/bip-frost-signing
#
#commit f5ea4a5b58c13c234f10b96b620d34e926b49127
#Merge: a599e50 8b2eaa9
#Author: Sivaram <siv2ram@gmail.com>
#Date: Sat Jun 21 19:59:11 2025 +0530
#
# Merge pull request #23 from theStack/integrate_secp256k1lab

1310
src/jmfrost/chilldkg_ref/README.md

File diff suppressed because it is too large Load Diff

3
src/jmfrost/chilldkg_ref/__init__.py

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
__all__ = ["chilldkg"]

871
src/jmfrost/chilldkg_ref/chilldkg.py

@ -0,0 +1,871 @@
"""Reference implementation of ChillDKG.
WARNING: This code is slow and trivially vulnerable to side channel attacks. Do
not use for anything but tests.
The public API consists of all functions with docstrings, including the types in
their arguments and return values, and the exceptions they raise; see also the
`__all__` list. All other definitions are internal.
"""
from secrets import token_bytes as random_bytes
from typing import Any, Tuple, List, NamedTuple, NewType, Optional, NoReturn, Dict
from ..secp256k1lab.secp256k1 import Scalar, GE
from ..secp256k1lab.bip340 import schnorr_sign, schnorr_verify
from ..secp256k1lab.keys import pubkey_gen_plain
from ..secp256k1lab.util import bytes_from_int
from .vss import VSSCommitment
from . import encpedpop
from .util import (
BIP_TAG,
tagged_hash_bip_dkg,
ProtocolError,
FaultyParticipantOrCoordinatorError,
FaultyCoordinatorError,
UnknownFaultyParticipantOrCoordinatorError,
FaultyParticipantError,
)
__all__ = [
# Functions
"hostpubkey_gen",
"params_id",
"participant_step1",
"participant_step2",
"participant_finalize",
"participant_investigate",
"coordinator_step1",
"coordinator_finalize",
"coordinator_investigate",
"recover",
# Exceptions
"InvalidSignatureInCertificateError",
"HostSeckeyError",
"SessionParamsError",
"InvalidHostPubkeyError",
"DuplicateHostPubkeyError",
"ThresholdOrCountError",
"RandomnessError",
"ProtocolError",
"FaultyParticipantError",
"FaultyParticipantOrCoordinatorError",
"FaultyCoordinatorError",
"UnknownFaultyParticipantOrCoordinatorError",
"RecoveryDataError",
# Types
"SessionParams",
"DKGOutput",
"ParticipantMsg1",
"ParticipantMsg2",
"CoordinatorInvestigationMsg",
"ParticipantState1",
"ParticipantState2",
"CoordinatorMsg1",
"CoordinatorMsg2",
"CoordinatorState",
"RecoveryData",
]
###
### Equality check protocol CertEq
###
def certeq_message(x: bytes, idx: int) -> bytes:
# Domain separation as described in BIP 340
prefix = (BIP_TAG + "certeq message").encode()
prefix = prefix + b"\x00" * (33 - len(prefix))
return prefix + idx.to_bytes(4, "big") + x
def certeq_participant_step(hostseckey: bytes, idx: int, x: bytes) -> bytes:
msg = certeq_message(x, idx)
return schnorr_sign(msg, hostseckey, aux_rand=random_bytes(32))
def certeq_cert_len(n: int) -> int:
return 64 * n
def certeq_verify(hostpubkeys: List[bytes], x: bytes, cert: bytes) -> None:
n = len(hostpubkeys)
if len(cert) != certeq_cert_len(n):
raise ValueError
for i in range(n):
msg = certeq_message(x, i)
valid = schnorr_verify(
msg,
hostpubkeys[i][1:33],
cert[i * 64 : (i + 1) * 64],
)
if not valid:
raise InvalidSignatureInCertificateError(i)
def certeq_coordinator_step(sigs: List[bytes]) -> bytes:
cert = b"".join(sigs)
return cert
class InvalidSignatureInCertificateError(ValueError):
def __init__(self, participant: int, *args: Any):
self.participant = participant
super().__init__(participant, *args)
###
### Host keys
###
def hostpubkey_gen(hostseckey: bytes) -> bytes:
"""Compute the participant's host public key from the host secret key.
The host public key is the long-term cryptographic identity of the
participant.
This function interprets `hostseckey` as big-endian integer, and computes
the corresponding "plain" public key in compressed serialization (33 bytes,
starting with 0x02 or 0x03). This is the key generation procedure
traditionally used in Bitcoin, e.g., for ECDSA. In other words, this
function is equivalent to `IndividualPubkey` as defined in
[[BIP 327](https://github.com/bitcoin/bips/blob/master/bip-0327.mediawiki#key-generation-of-an-individual-signer)].
TODO Refer to the FROST signing BIP instead, once that one has a number.
Arguments:
hostseckey: This participant's long-term secret key (32 bytes).
The key **must** be 32 bytes of cryptographically secure randomness
with sufficient entropy to be unpredictable. All outputs of a
successful participant in a session can be recovered from (a backup
of) the key and per-session recovery data.
The same host secret key (and thus the same host public key) can be
used in multiple DKG sessions. A host public key can be correlated
to the threshold public key resulting from a DKG session only by
parties who observed the session, namely the participants, the
coordinator (and any eavesdropper).
Returns:
The host public key (33 bytes).
Raises:
HostSeckeyError: If the length of `hostseckey` is not 32 bytes or if the
key is invalid.
"""
if len(hostseckey) != 32:
raise HostSeckeyError
try:
return pubkey_gen_plain(hostseckey)
except ValueError:
raise HostSeckeyError
class HostSeckeyError(ValueError):
"""Raised if the host secret key is invalid.
This incluces the case that its length is not 32 bytes."""
###
### Session input and outputs
###
# It would be more idiomatic Python to make this a real (data)class, perform
# data validation in the constructor, and add methods to it, but let's stick to
# simple tuples in the public API in order to keep it approachable to readers
# who are not too familiar with Python.
class SessionParams(NamedTuple):
"""A `SessionParams` tuple holds the common parameters of a DKG session.
Attributes:
hostpubkeys: Ordered list of the host public keys of all participants.
t: The participation threshold `t`.
This is the number of participants that will be required to sign.
It must hold that `1 <= t <= len(hostpubkeys) <= 2**32 - 1`.
Participants **must** ensure that they have obtained authentic host
public keys of all the other participants in the session to make
sure that they run the DKG and generate a threshold public key with
the intended set of participants. This is analogous to traditional
threshold signatures (known as "multisig" in the Bitcoin community),
[[BIP 383](https://github.com/bitcoin/bips/blob/master/bip-0383.mediawiki)],
where the participants need to obtain authentic extended public keys
("xpubs") from the other participants to generate multisig
addresses, or MuSig2
[[BIP 327](https://github.com/bitcoin/bips/blob/master/bip-0327.mediawiki)],
where the participants need to obtain authentic individual public
keys of the other participants to generate an aggregated public key.
A DKG session will fail if the participants and the coordinator in a session
don't have the `hostpubkeys` in the same order. This will make sure that
honest participants agree on the order as part of the session, which is
useful if the order carries an implicit meaning in the application (e.g., if
the first `t` participants are the primary participants for signing and the
others are fallback participants). If there is no canonical order of the
participants in the application, the caller can sort the list of host public
keys with the [KeySort algorithm specified in
BIP 327](https://github.com/bitcoin/bips/blob/master/bip-0327.mediawiki#key-sorting)
to abstract away from the order.
"""
hostpubkeys: List[bytes]
t: int
def params_validate(params: SessionParams) -> None:
(hostpubkeys, t) = params
if not (1 <= t <= len(hostpubkeys) <= 2**32 - 1):
raise ThresholdOrCountError
# Check that all hostpubkeys are valid
for i, hostpubkey in enumerate(hostpubkeys):
try:
_ = GE.from_bytes_compressed(hostpubkey)
except ValueError as e:
raise InvalidHostPubkeyError(i) from e
# Check for duplicate hostpubkeys and find the corresponding indices
hostpubkey_to_idx: Dict[bytes, int] = dict()
for i, hostpubkey in enumerate(hostpubkeys):
if hostpubkey in hostpubkey_to_idx:
raise DuplicateHostPubkeyError(hostpubkey_to_idx[hostpubkey], i)
hostpubkey_to_idx[hostpubkey] = i
def params_id(params: SessionParams) -> bytes:
"""Return the parameters ID, a unique representation of the `SessionParams`.
In the common scenario that the participants obtain host public keys from
the other participants over channels that do not provide end-to-end
authentication of the sending participant (e.g., if the participants simply
send their unauthenticated host public keys to the coordinator, who is
supposed to relay them to all participants), the parameters ID serves as a
convenient way to perform an out-of-band comparison of all host public keys.
It is a collision-resistant cryptographic hash of the `SessionParams`
tuple. As a result, if all participants have obtained an identical
parameters ID (as can be verified out-of-band), then they all agree on all
host public keys and the threshold `t`, and in particular, all participants
have obtained authentic public host keys.
Returns:
bytes: The parameters ID, a 32-byte string.
Raises:
InvalidHostPubkeyError: If `hostpubkeys` contains an invalid public key.
DuplicateHostPubkeyError: If `hostpubkeys` contains duplicates.
ThresholdOrCountError: If `1 <= t <= len(hostpubkeys) <= 2**32 - 1` does
not hold.
"""
params_validate(params)
hostpubkeys, t = params
t_bytes = t.to_bytes(4, byteorder="big")
params_id = tagged_hash_bip_dkg(
"params_id",
t_bytes + b"".join(hostpubkeys),
)
assert len(params_id) == 32
return params_id
class SessionParamsError(ValueError):
"""Base exception for invalid `SessionParams` tuples."""
class DuplicateHostPubkeyError(SessionParamsError):
"""Raised if two participants have identical host public keys.
This exception is raised when two participants have an identical host public
key in the `SessionParams` tuple. Assuming the host public keys in question
have been transmitted correctly, this exception implies that at least one of
the two participants is faulty (because duplicates occur only with
negligible probability if keys are generated honestly).
Attributes:
participant1 (int): Index of the first participant.
participant2 (int): Index of the second participant.
"""
def __init__(self, participant1: int, participant2: int, *args: Any):
self.participant1 = participant1
self.participant2 = participant2
super().__init__(participant1, participant2, *args)
class InvalidHostPubkeyError(SessionParamsError):
"""Raised if a host public key is invalid.
This exception is raised when a host public key in the `SessionParams` tuple
is not a valid public key in compressed serialization. Assuming the host
public keys in question has been transmitted correctly, this exception
implies that the corresponding participant is faulty.
Attributes:
participant (int): Index of the participant.
"""
def __init__(self, participant: int, *args: Any):
self.participant = participant
super().__init__(participant, *args)
class ThresholdOrCountError(SessionParamsError):
"""Raised if `1 <= t <= len(hostpubkeys) <= 2**32 - 1` does not hold."""
# This is really the same definition as in simplpedpop and encpedpop. We repeat
# it here only to have its docstring in this module.
class DKGOutput(NamedTuple):
"""Holds the outputs of a DKG session.
Attributes:
secshare: Secret share of the participant (or `None` for coordinator)
threshold_pubkey: Generated threshold public key representing the group
pubshares: Public shares of the participants
"""
secshare: Optional[bytes]
threshold_pubkey: bytes
pubshares: List[bytes]
RecoveryData = NewType("RecoveryData", bytes)
###
### Messages
###
class ParticipantMsg1(NamedTuple):
enc_pmsg: encpedpop.ParticipantMsg
class ParticipantMsg2(NamedTuple):
sig: bytes
class CoordinatorMsg1(NamedTuple):
enc_cmsg: encpedpop.CoordinatorMsg
enc_secshares: List[Scalar]
class CoordinatorMsg2(NamedTuple):
cert: bytes
class CoordinatorInvestigationMsg(NamedTuple):
enc_cinv: encpedpop.CoordinatorInvestigationMsg
def deserialize_recovery_data(
b: bytes,
) -> Tuple[int, VSSCommitment, List[bytes], List[bytes], List[Scalar], bytes]:
rest = b
# Read t (4 bytes)
if len(rest) < 4:
raise ValueError
t, rest = int.from_bytes(rest[:4], byteorder="big"), rest[4:]
# Read sum_coms (33*t bytes)
if len(rest) < 33 * t:
raise ValueError
sum_coms, rest = (
VSSCommitment.from_bytes_and_t(rest[: 33 * t], t),
rest[33 * t :],
)
# Compute n
n, remainder = divmod(len(rest), (33 + 33 + 32 + 64))
if remainder != 0:
raise ValueError
# Read hostpubkeys (33*n bytes)
if len(rest) < 33 * n:
raise ValueError
hostpubkeys, rest = [rest[i : i + 33] for i in range(0, 33 * n, 33)], rest[33 * n :]
# Read pubnonces (33*n bytes)
if len(rest) < 33 * n:
raise ValueError
pubnonces, rest = [rest[i : i + 33] for i in range(0, 33 * n, 33)], rest[33 * n :]
# Read enc_secshares (32*n bytes)
if len(rest) < 32 * n:
raise ValueError
enc_secshares, rest = (
[Scalar.from_bytes_checked(rest[i : i + 32]) for i in range(0, 32 * n, 32)],
rest[32 * n :],
)
# Read cert
cert_len = certeq_cert_len(n)
if len(rest) < cert_len:
raise ValueError
cert, rest = rest[:cert_len], rest[cert_len:]
if len(rest) != 0:
raise ValueError
return (t, sum_coms, hostpubkeys, pubnonces, enc_secshares, cert)
###
### Participant
###
class ParticipantState1(NamedTuple):
params: SessionParams
idx: int
enc_state: encpedpop.ParticipantState
class ParticipantState2(NamedTuple):
params: SessionParams
eq_input: bytes
dkg_output: DKGOutput
def participant_step1(
hostseckey: bytes, params: SessionParams, random: bytes
) -> Tuple[ParticipantState1, ParticipantMsg1]:
"""Perform a participant's first step of a ChillDKG session.
Arguments:
hostseckey: Participant's long-term host secret key (32 bytes).
params: Common session parameters.
random: FRESH random byte string (32 bytes).
Returns:
ParticipantState1: The participant's session state after this step, to
be passed as an argument to `participant_step2`. The state **must
not** be reused (i.e., it must be passed only to one
`participant_step2` call).
ParticipantMsg1: The first message to be sent to the coordinator.
Raises:
HostSeckeyError: If the length of `hostseckey` is not 32 bytes, if the
key is invalid, or if the key does not match any entry of
`hostpubkeys`.
InvalidHostPubkeyError: If `hostpubkeys` contains an invalid public key.
DuplicateHostPubkeyError: If `hostpubkeys` contains duplicates.
ThresholdOrCountError: If `1 <= t <= len(hostpubkeys) <= 2**32 - 1` does
not hold.
RandomnessError: If the length of `random` is not 32 bytes.
"""
hostpubkey = hostpubkey_gen(hostseckey) # HostSeckeyError if len(hostseckey) != 32
params_validate(params)
(hostpubkeys, t) = params
try:
idx = hostpubkeys.index(hostpubkey)
except ValueError as e:
raise HostSeckeyError(
"Host secret key does not match any host public key"
) from e
if len(random) != 32:
raise RandomnessError
enc_state, enc_pmsg = encpedpop.participant_step1(
# We know that EncPedPop uses its seed only by feeding it to a hash
# function. Thus, it is sufficient that the seed has a high entropy,
# and so we can simply pass the hostseckey as seed.
seed=hostseckey,
deckey=hostseckey,
t=t,
# This requires the joint security of Schnorr signatures and ECDH.
enckeys=hostpubkeys,
idx=idx,
random=random,
) # HostSeckeyError if len(hostseckey) != 32
state1 = ParticipantState1(params, idx, enc_state)
return state1, ParticipantMsg1(enc_pmsg)
class RandomnessError(ValueError):
"""Raised if the length of the provided randomness is not 32 bytes."""
def participant_step2(
hostseckey: bytes,
state1: ParticipantState1,
cmsg1: CoordinatorMsg1,
) -> Tuple[ParticipantState2, ParticipantMsg2]:
"""Perform a participant's second step of a ChillDKG session.
**Warning:**
After sending the returned message to the coordinator, this participant
**must not** erase the hostseckey, even if this participant does not receive
the coordinator reply needed for the `participant_finalize` call. The
underlying reason is that some other participant may receive the coordinator
reply, deem the DKG session successful and use the resulting threshold
public key (e.g., by sending funds to it). If the coordinator reply remains
missing, that other participant can, at any point in the future, convince
this participant of the success of the DKG session by presenting recovery
data, from which this participant can recover the DKG output using the
`recover` function.
Arguments:
hostseckey: Participant's long-term host secret key (32 bytes).
state1: The participant's session state as output by
`participant_step1`.
cmsg1: The first message received from the coordinator.
Returns:
ParticipantState2: The participant's session state after this step, to
be passed as an argument to `participant_finalize`. The state **must
not** be reused (i.e., it must be passed only to one
`participant_finalize` call).
ParticipantMsg2: The second message to be sent to the coordinator.
Raises:
HostSeckeyError: If the length of `hostseckey` is not 32 bytes.
FaultyCoordinatorError: If the coordinator is faulty. See the
documentation of the exception for further details.
FaultyParticipantOrCoordinatorError: If another known participant or the
coordinator is faulty. See the documentation of the exception for
further details.
UnknownFaultyParticipantOrCoordinatorError: If another unknown
participant or the coordinator is faulty, but running the optional
investigation procedure of the protocol is necessary to determine a
suspected participant. See the documentation of the exception for
further details.
"""
if len(hostseckey) != 32:
raise HostSeckeyError
params, idx, enc_state = state1
enc_cmsg, enc_secshares = cmsg1
enc_dkg_output, eq_input = encpedpop.participant_step2(
state=enc_state,
deckey=hostseckey,
cmsg=enc_cmsg,
enc_secshare=enc_secshares[idx],
)
# Include the enc_shares in eq_input to ensure that participants agree on
# all shares, which in turn ensures that they have the right recovery data.
eq_input += b"".join([bytes_from_int(int(share)) for share in enc_secshares])
dkg_output = DKGOutput._make(enc_dkg_output)
state2 = ParticipantState2(params, eq_input, dkg_output)
sig = certeq_participant_step(hostseckey, idx, eq_input)
pmsg2 = ParticipantMsg2(sig)
return state2, pmsg2
def participant_finalize(
state2: ParticipantState2, cmsg2: CoordinatorMsg2
) -> Tuple[DKGOutput, RecoveryData]:
"""Perform a participant's final step of a ChillDKG session.
If this function returns properly (without an exception), then this
participant deems the DKG session successful. It is, however, possible that
other participants have received a `cmsg2` from the coordinator that made
them raise an exception instead, or that they have not received a `cmsg2`
from the coordinator at all. These participants can, at any point in time in
the future (e.g., when initiating a signing session), be convinced to deem
the session successful by presenting the recovery data to them, from which
they can recover the DKG outputs using the `recover` function.
**Warning:**
Changing perspectives, this implies that, even when obtaining an exception,
this participant **must not** conclude that the DKG session has failed, and
as a consequence, this particiant **must not** erase the hostseckey. The
underlying reason is that some other participant may deem the DKG session
successful and use the resulting threshold public key (e.g., by sending
funds to it). That other participant can, at any point in the future,
convince this participant of the success of the DKG session by presenting
recovery data to this participant.
Arguments:
state2: The participant's state as output by `participant_step2`.
cmsg2: The second message received from the coordinator.
Returns:
DKGOutput: The DKG output.
bytes: The serialized recovery data.
Raises:
FaultyParticipantOrCoordinatorError: If another known participant or the
coordinator is faulty. Make sure to read the above warning, and see
the documentation of the exception for further details.
FaultyCoordinatorError: If the coordinator is faulty. Make sure to read
the above warning, and see the documentation of the exception for
further details.
"""
params, eq_input, dkg_output = state2
try:
certeq_verify(params.hostpubkeys, eq_input, cmsg2.cert)
except InvalidSignatureInCertificateError as e:
raise FaultyParticipantOrCoordinatorError(
e.participant,
"Participant has provided an invalid signature for the certificate",
) from e
return dkg_output, RecoveryData(eq_input + cmsg2.cert)
def participant_investigate(
error: UnknownFaultyParticipantOrCoordinatorError,
cinv: CoordinatorInvestigationMsg,
) -> NoReturn:
"""Investigate who is to blame for a failed ChillDKG session.
This function can optionally be called when `participant_step2` raises
`UnknownFaultyParticipantOrCoordinatorError`. It narrows down the suspected
faulty parties by analyzing the investigation message provided by the coordinator.
This function does not return normally. Instead, it raises one of two
exceptions.
Arguments:
error: `UnknownFaultyParticipantOrCoordinatorError` raised by
`participant_step2`.
cinv: Coordinator investigation message for this participant as output
by `coordinator_investigate`.
Raises:
FaultyParticipantOrCoordinatorError: If another known participant or the
coordinator is faulty. See the documentation of the exception for
further details.
FaultyCoordinatorError: If the coordinator is faulty. See the
documentation of the exception for further details.
"""
assert isinstance(error.inv_data, encpedpop.ParticipantInvestigationData)
encpedpop.participant_investigate(
error=error,
cinv=cinv.enc_cinv,
)
###
### Coordinator
###
class CoordinatorState(NamedTuple):
params: SessionParams
eq_input: bytes
dkg_output: DKGOutput
def coordinator_step1(
pmsgs1: List[ParticipantMsg1], params: SessionParams
) -> Tuple[CoordinatorState, CoordinatorMsg1]:
"""Perform the coordinator's first step of a ChillDKG session.
Arguments:
pmsgs1: List of first messages received from the participants. The
list's length must equal the total number of participants.
params: Common session parameters.
Returns:
CoordinatorState: The coordinator's session state after this step, to be
passed as an argument to `coordinator_finalize`. The state is not
supposed to be reused (i.e., it should be passed only to one
`coordinator_finalize` call).
CoordinatorMsg1: The first message to be sent to all participants.
Raises:
InvalidHostPubkeyError: If `hostpubkeys` contains an invalid public key.
DuplicateHostPubkeyError: If `hostpubkeys` contains duplicates.
ThresholdOrCountError: If `1 <= t <= len(hostpubkeys) <= 2**32 - 1` does
not hold.
FaultyParticipantError: If another participant is faulty. See the
documentation of the exception for further details.
"""
params_validate(params)
hostpubkeys, t = params
enc_cmsg, enc_dkg_output, eq_input, enc_secshares = encpedpop.coordinator_step(
pmsgs=[pmsg1.enc_pmsg for pmsg1 in pmsgs1],
t=t,
enckeys=hostpubkeys,
)
eq_input += b"".join([bytes_from_int(int(share)) for share in enc_secshares])
dkg_output = DKGOutput._make(enc_dkg_output) # Convert to chilldkg.DKGOutput type
state = CoordinatorState(params, eq_input, dkg_output)
cmsg1 = CoordinatorMsg1(enc_cmsg, enc_secshares)
return state, cmsg1
def coordinator_finalize(
state: CoordinatorState, pmsgs2: List[ParticipantMsg2]
) -> Tuple[CoordinatorMsg2, DKGOutput, RecoveryData]:
"""Perform the coordinator's final step of a ChillDKG session.
If this function returns properly (without an exception), then the
coordinator deems the DKG session successful. The returned `CoordinatorMsg2`
is supposed to be sent to all participants, who are supposed to pass it as
input to the `participant_finalize` function. It is, however, possible that
some participants pass a wrong and invalid message to `participant_finalize`
(e.g., because the message is transmitted incorrectly). These participants
can, at any point in time in the future (e.g., when initiating a signing
session), be convinced to deem the session successful by presenting the
recovery data to them, from which they can recover the DKG outputs using the
`recover` function.
If this function raises an exception, then the DKG session was not
successful from the perspective of the coordinator. In this case, it is, in
principle, possible to recover the DKG outputs of the coordinator using the
recovery data from a successful participant, should one exist. Any such
successful participant is either faulty, or has received messages from
other participants via a communication channel beside the coordinator.
Arguments:
state: The coordinator's session state as output by `coordinator_step1`.
pmsgs2: List of second messages received from the participants. The
list's length must equal the total number of participants.
Returns:
CoordinatorMsg2: The second message to be sent to all participants.
DKGOutput: The DKG output. Since the coordinator does not have a secret
share, the DKG output will have the `secshare` field set to `None`.
bytes: The serialized recovery data.
Raises:
FaultyParticipantError: If another participant is faulty. See the
documentation of the exception for further details.
"""
params, eq_input, dkg_output = state
if len(pmsgs2) != len(params.hostpubkeys):
raise ValueError
cert = certeq_coordinator_step([pmsg2.sig for pmsg2 in pmsgs2])
try:
certeq_verify(params.hostpubkeys, eq_input, cert)
except InvalidSignatureInCertificateError as e:
raise FaultyParticipantError(
e.participant,
"Participant has provided an invalid signature for the certificate",
) from e
return CoordinatorMsg2(cert), dkg_output, RecoveryData(eq_input + cert)
def coordinator_investigate(
pmsgs: List[ParticipantMsg1],
) -> List[CoordinatorInvestigationMsg]:
"""Generate investigation messages for a ChillDKG session.
The investigation messages will allow the participants to investigate who is
to blame for a failed ChillDKG session (see `participant_investigate`).
Each message is intended for a single participant but can be safely
broadcast to all participants because the messages contain no confidential
information.
Arguments:
pmsgs: List of first messages received from the participants.
Returns:
List[CoordinatorInvestigationMsg]: A list of investigation messages, each
intended for a single participant.
"""
enc_cinvs = encpedpop.coordinator_investigate([pmsg.enc_pmsg for pmsg in pmsgs])
return [CoordinatorInvestigationMsg(enc_cinv) for enc_cinv in enc_cinvs]
###
### Recovery
###
def recover(
hostseckey: Optional[bytes], recovery_data: RecoveryData
) -> Tuple[DKGOutput, SessionParams]:
"""Recover the DKG output of a ChillDKG session.
This function serves two different purposes:
1. To recover from an exception in `participant_finalize` or
`coordinator_finalize`, after obtaining the recovery data from another
participant or the coordinator. See `participant_finalize` and
`coordinator_finalize` for background.
2. To reproduce the DKG outputs on a new device, e.g., to recover from a
backup after data loss.
Arguments:
hostseckey: This participant's long-term host secret key (32 bytes) or
`None` if recovering the coordinator.
recovery_data: Recovery data from a successful session.
Returns:
DKGOutput: The recovered DKG output.
SessionParams: The common parameters of the recovered session.
Raises:
HostSeckeyError: If the length of `hostseckey` is not 32 bytes, if the
key is invalid, or if the key does not match the recovery data.
(This can also occur if the recovery data is invalid.)
RecoveryDataError: If recovery failed due to invalid recovery data.
"""
try:
(t, sum_coms, hostpubkeys, pubnonces, enc_secshares, cert) = (
deserialize_recovery_data(recovery_data)
)
except Exception as e:
raise RecoveryDataError("Failed to deserialize recovery data") from e
n = len(hostpubkeys)
params = SessionParams(hostpubkeys, t)
try:
params_validate(params)
except SessionParamsError as e:
raise RecoveryDataError("Invalid session parameters in recovery data") from e
# Verify cert
eq_input = recovery_data[: -len(cert)]
try:
certeq_verify(hostpubkeys, eq_input, cert)
except InvalidSignatureInCertificateError as e:
raise RecoveryDataError("Invalid certificate in recovery data") from e
# Compute threshold pubkey and individual pubshares
sum_coms, tweak, _ = sum_coms.invalid_taproot_commit()
threshold_pubkey = sum_coms.commitment_to_secret()
pubshares = [sum_coms.pubshare(i) for i in range(n)]
if hostseckey:
hostpubkey = hostpubkey_gen(hostseckey) # HostSeckeyError
try:
idx = hostpubkeys.index(hostpubkey)
except ValueError as e:
raise HostSeckeyError(
"Host secret key does not match any host public key in the recovery data"
) from e
# Decrypt share
enc_context = encpedpop.serialize_enc_context(t, hostpubkeys)
secshare = encpedpop.decrypt_sum(
hostseckey,
hostpubkeys[idx],
pubnonces,
enc_context,
idx,
enc_secshares[idx],
)
secshare_tweaked = secshare + tweak
# This is just a sanity check. Our signature is valid, so we have done
# an equivalent check already during the actual session.
assert VSSCommitment.verify_secshare(secshare_tweaked, pubshares[idx])
else:
secshare_tweaked = None
dkg_output = DKGOutput(
None if secshare_tweaked is None else secshare_tweaked.to_bytes(),
threshold_pubkey.to_bytes_compressed(),
[pubshare.to_bytes_compressed() for pubshare in pubshares],
)
return dkg_output, params
class RecoveryDataError(ValueError):
"""Raised if the recovery data is invalid."""

341
src/jmfrost/chilldkg_ref/encpedpop.py

@ -0,0 +1,341 @@
from typing import Tuple, List, NamedTuple, NoReturn
from ..secp256k1lab.secp256k1 import Scalar, GE
from ..secp256k1lab.ecdh import ecdh_libsecp256k1
from ..secp256k1lab.keys import pubkey_gen_plain
from . import simplpedpop
from .util import (
UnknownFaultyParticipantOrCoordinatorError,
tagged_hash_bip_dkg,
FaultyParticipantError,
FaultyCoordinatorError,
)
###
### Encryption
###
def ecdh(
seckey: bytes, my_pubkey: bytes, their_pubkey: bytes, context: bytes, sending: bool
) -> Scalar:
data = ecdh_libsecp256k1(seckey, their_pubkey)
if sending:
data += my_pubkey + their_pubkey
else:
data += their_pubkey + my_pubkey
assert len(data) == 32 + 2 * 33
data += context
ret: Scalar = Scalar.from_bytes_wrapping(
tagged_hash_bip_dkg("encpedpop ecdh", data)
)
return ret
def self_pad(symkey: bytes, nonce: bytes, context: bytes) -> Scalar:
# Pad for symmetric encryption to ourselves
pad: Scalar = Scalar.from_bytes_wrapping(
tagged_hash_bip_dkg("encaps_multi self_pad", symkey + nonce + context)
)
return pad
def encaps_multi(
secnonce: bytes,
pubnonce: bytes,
deckey: bytes,
enckeys: List[bytes],
context: bytes,
idx: int,
) -> List[Scalar]:
# This is effectively the "Hashed ElGamal" multi-recipient KEM described in
# Section 5 of "Multi-recipient encryption, revisited" by Alexandre Pinto,
# Bertram Poettering, Jacob C. N. Schuldt (AsiaCCS 2014). Its crucial
# feature is to feed the index of the enckey to the hash function. The only
# difference is that we feed also the pubnonce and context data into the
# hash function.
pads = []
for i, enckey in enumerate(enckeys):
context_ = i.to_bytes(4, byteorder="big") + context
if i == idx:
# We're encrypting to ourselves, so we use a symmetrically derived
# pad to save the ECDH computation.
pad = self_pad(symkey=deckey, nonce=pubnonce, context=context_)
else:
pad = ecdh(
seckey=secnonce,
my_pubkey=pubnonce,
their_pubkey=enckey,
context=context_,
sending=True,
)
pads.append(pad)
return pads
def encrypt_multi(
secnonce: bytes,
pubnonce: bytes,
deckey: bytes,
enckeys: List[bytes],
context: bytes,
idx: int,
plaintexts: List[Scalar],
) -> List[Scalar]:
pads = encaps_multi(secnonce, pubnonce, deckey, enckeys, context, idx)
if len(plaintexts) != len(pads):
raise ValueError
ciphertexts = [plaintext + pad for plaintext, pad in zip(plaintexts, pads)]
return ciphertexts
def decaps_multi(
deckey: bytes,
enckey: bytes,
pubnonces: List[bytes],
context: bytes,
idx: int,
) -> List[Scalar]:
context_ = idx.to_bytes(4, byteorder="big") + context
pads = []
for sender_idx, pubnonce in enumerate(pubnonces):
if sender_idx == idx:
pad = self_pad(symkey=deckey, nonce=pubnonce, context=context_)
else:
pad = ecdh(
seckey=deckey,
my_pubkey=enckey,
their_pubkey=pubnonce,
context=context_,
sending=False,
)
pads.append(pad)
return pads
def decrypt_sum(
deckey: bytes,
enckey: bytes,
pubnonces: List[bytes],
context: bytes,
idx: int,
sum_ciphertexts: Scalar,
) -> Scalar:
if idx >= len(pubnonces):
raise IndexError
pads = decaps_multi(deckey, enckey, pubnonces, context, idx)
sum_plaintexts: Scalar = sum_ciphertexts - Scalar.sum(*pads)
return sum_plaintexts
###
### Messages
###
class ParticipantMsg(NamedTuple):
simpl_pmsg: simplpedpop.ParticipantMsg
pubnonce: bytes
enc_shares: List[Scalar]
class CoordinatorMsg(NamedTuple):
simpl_cmsg: simplpedpop.CoordinatorMsg
pubnonces: List[bytes]
class CoordinatorInvestigationMsg(NamedTuple):
enc_partial_secshares: List[Scalar]
partial_pubshares: List[GE]
###
### Participant
###
class ParticipantState(NamedTuple):
simpl_state: simplpedpop.ParticipantState
pubnonce: bytes
enckeys: List[bytes]
idx: int
class ParticipantInvestigationData(NamedTuple):
simpl_bstate: simplpedpop.ParticipantInvestigationData
enc_secshare: Scalar
pads: List[Scalar]
def serialize_enc_context(t: int, enckeys: List[bytes]) -> bytes:
return t.to_bytes(4, byteorder="big") + b"".join(enckeys)
def participant_step1(
seed: bytes,
deckey: bytes,
enckeys: List[bytes],
t: int,
idx: int,
random: bytes,
) -> Tuple[ParticipantState, ParticipantMsg]:
if t >= 2 ** (4 * 8):
raise ValueError
if len(random) != 32:
raise ValueError
n = len(enckeys)
# Derive an encryption nonce and a seed for SimplPedPop.
#
# SimplPedPop will use its seed to derive the secret shares, which we will
# encrypt using the encryption nonce. That means that all entropy used in
# the derivation of simpl_seed should also be in the derivation of the
# pubnonce, to ensure that we never encrypt different secret shares with the
# same encryption pads. The foolproof way to achieve this is to simply
# derive the nonce from simpl_seed.
enc_context = serialize_enc_context(t, enckeys)
simpl_seed = tagged_hash_bip_dkg("encpedpop seed", seed + random + enc_context)
secnonce = tagged_hash_bip_dkg("encpedpop secnonce", simpl_seed)
pubnonce = pubkey_gen_plain(secnonce)
simpl_state, simpl_pmsg, shares = simplpedpop.participant_step1(
simpl_seed, t, n, idx
)
assert len(shares) == n
enc_shares = encrypt_multi(
secnonce, pubnonce, deckey, enckeys, enc_context, idx, shares
)
pmsg = ParticipantMsg(simpl_pmsg, pubnonce, enc_shares)
state = ParticipantState(simpl_state, pubnonce, enckeys, idx)
return state, pmsg
def participant_step2(
state: ParticipantState,
deckey: bytes,
cmsg: CoordinatorMsg,
enc_secshare: Scalar,
) -> Tuple[simplpedpop.DKGOutput, bytes]:
simpl_state, pubnonce, enckeys, idx = state
simpl_cmsg, pubnonces = cmsg
reported_pubnonce = pubnonces[idx]
if reported_pubnonce != pubnonce:
raise FaultyCoordinatorError("Coordinator replied with wrong pubnonce")
enc_context = serialize_enc_context(simpl_state.t, enckeys)
pads = decaps_multi(deckey, enckeys[idx], pubnonces, enc_context, idx)
secshare = enc_secshare - Scalar.sum(*pads)
try:
dkg_output, eq_input = simplpedpop.participant_step2(
simpl_state, simpl_cmsg, secshare
)
except UnknownFaultyParticipantOrCoordinatorError as e:
assert isinstance(e.inv_data, simplpedpop.ParticipantInvestigationData)
# Translate simplpedpop.ParticipantInvestigationData into our own
# encpedpop.ParticipantInvestigationData.
inv_data = ParticipantInvestigationData(e.inv_data, enc_secshare, pads)
raise UnknownFaultyParticipantOrCoordinatorError(inv_data, e.args) from e
eq_input += b"".join(enckeys) + b"".join(pubnonces)
return dkg_output, eq_input
def participant_investigate(
error: UnknownFaultyParticipantOrCoordinatorError,
cinv: CoordinatorInvestigationMsg,
) -> NoReturn:
simpl_inv_data, enc_secshare, pads = error.inv_data
enc_partial_secshares, partial_pubshares = cinv
if len(enc_partial_secshares) != len(pads):
raise ValueError
partial_secshares = [
enc_partial_secshare - pad
for enc_partial_secshare, pad in zip(enc_partial_secshares, pads)
]
simpl_cinv = simplpedpop.CoordinatorInvestigationMsg(partial_pubshares)
try:
simplpedpop.participant_investigate(
UnknownFaultyParticipantOrCoordinatorError(simpl_inv_data),
simpl_cinv,
partial_secshares,
)
except simplpedpop.SecshareSumError as e:
# The secshare is not equal to the sum of the partial secshares in the
# investigation message. Since the encryption is additively homomorphic,
# this can only happen if the sum of the *encrypted* secshare is not
# equal to the sum of the encrypted partial sechares, which is the
# coordinator's fault.
assert Scalar.sum(*enc_partial_secshares) != enc_secshare
raise FaultyCoordinatorError(
"Sum of encrypted partial secshares not equal to encrypted secshare"
) from e
###
### Coordinator
###
def coordinator_step(
pmsgs: List[ParticipantMsg],
t: int,
enckeys: List[bytes],
) -> Tuple[CoordinatorMsg, simplpedpop.DKGOutput, bytes, List[Scalar]]:
n = len(enckeys)
if n != len(pmsgs):
raise ValueError
simpl_pmsgs = [pmsg.simpl_pmsg for pmsg in pmsgs]
simpl_cmsg, dkg_output, eq_input = simplpedpop.coordinator_step(simpl_pmsgs, t, n)
pubnonces = [pmsg.pubnonce for pmsg in pmsgs]
for i in range(n):
if len(pmsgs[i].enc_shares) != n:
raise FaultyParticipantError(
i, "Participant sent enc_shares with invalid length"
)
enc_secshares = [
Scalar.sum(*([pmsg.enc_shares[i] for pmsg in pmsgs])) for i in range(n)
]
eq_input += b"".join(enckeys) + b"".join(pubnonces)
# In ChillDKG, the coordinator needs to broadcast the entire enc_secshares
# array to all participants. But in pure EncPedPop, the coordinator needs to
# send to each participant i only their entry enc_secshares[i].
#
# Since broadcasting the entire array is not necessary, we don't include it
# in encpedpop.CoordinatorMsg, but only return it as a side output, so that
# chilldkg.coordinator_step can pick it up. Implementations of pure
# EncPedPop will need to decide how to transmit enc_secshares[i] to
# participant i for participant_step2(); we leave this unspecified.
return (
CoordinatorMsg(simpl_cmsg, pubnonces),
dkg_output,
eq_input,
enc_secshares,
)
def coordinator_investigate(
pmsgs: List[ParticipantMsg],
) -> List[CoordinatorInvestigationMsg]:
n = len(pmsgs)
simpl_pmsgs = [pmsg.simpl_pmsg for pmsg in pmsgs]
all_enc_partial_secshares = [
[pmsg.enc_shares[i] for pmsg in pmsgs] for i in range(n)
]
simpl_cinvs = simplpedpop.coordinator_investigate(simpl_pmsgs)
cinvs = [
CoordinatorInvestigationMsg(
all_enc_partial_secshares[i], simpl_cinvs[i].partial_pubshares
)
for i in range(n)
]
return cinvs

327
src/jmfrost/chilldkg_ref/simplpedpop.py

@ -0,0 +1,327 @@
from secrets import token_bytes as random_bytes
from typing import List, NamedTuple, NewType, Tuple, Optional, NoReturn
from ..secp256k1lab.bip340 import schnorr_sign, schnorr_verify
from ..secp256k1lab.secp256k1 import GE, Scalar
from .util import (
BIP_TAG,
FaultyParticipantOrCoordinatorError,
FaultyCoordinatorError,
UnknownFaultyParticipantOrCoordinatorError,
)
from .vss import VSS, VSSCommitment
###
### Exceptions
###
class SecshareSumError(ValueError):
pass
###
### Proofs of possession (pops)
###
Pop = NewType("Pop", bytes)
POP_MSG_TAG = BIP_TAG + "pop message"
def pop_msg(idx: int) -> bytes:
return idx.to_bytes(4, byteorder="big")
def pop_prove(seckey: bytes, idx: int) -> Pop:
sig = schnorr_sign(
pop_msg(idx), seckey, aux_rand=random_bytes(32), tag_prefix=POP_MSG_TAG
)
return Pop(sig)
def pop_verify(pop: Pop, pubkey: bytes, idx: int) -> bool:
return schnorr_verify(pop_msg(idx), pubkey, pop, tag_prefix=POP_MSG_TAG)
###
### Messages
###
class ParticipantMsg(NamedTuple):
com: VSSCommitment
pop: Pop
class CoordinatorMsg(NamedTuple):
coms_to_secrets: List[GE]
sum_coms_to_nonconst_terms: List[GE]
pops: List[Pop]
def to_bytes(self) -> bytes:
return b"".join(
[
P.to_bytes_compressed_with_infinity()
for P in self.coms_to_secrets + self.sum_coms_to_nonconst_terms
]
) + b"".join(self.pops)
class CoordinatorInvestigationMsg(NamedTuple):
partial_pubshares: List[GE]
###
### Other common definitions
###
class DKGOutput(NamedTuple):
secshare: Optional[bytes] # None for coordinator
threshold_pubkey: bytes
pubshares: List[bytes]
def assemble_sum_coms(
coms_to_secrets: List[GE], sum_coms_to_nonconst_terms: List[GE]
) -> VSSCommitment:
# Sum the commitments to the secrets
return VSSCommitment(
[GE.sum(*(c for c in coms_to_secrets))] + sum_coms_to_nonconst_terms
)
###
### Participant
###
class ParticipantState(NamedTuple):
t: int
n: int
idx: int
com_to_secret: GE
class ParticipantInvestigationData(NamedTuple):
n: int
idx: int
secshare: Scalar
pubshare: GE
# To keep the algorithms of SimplPedPop and EncPedPop purely non-interactive
# computations, we omit explicit invocations of an interactive equality check
# protocol. ChillDKG will take care of invoking the equality check protocol.
def participant_step1(
seed: bytes, t: int, n: int, idx: int
) -> Tuple[
ParticipantState,
ParticipantMsg,
# The following return value is a list of n partial secret shares generated
# by this participant. The item at index i is supposed to be made available
# to participant i privately, e.g., via an external secure channel. See also
# the function participant_step2_prepare_secshare().
List[Scalar],
]:
if t > n:
raise ValueError
if idx >= n:
raise IndexError
if len(seed) != 32:
raise ValueError
vss = VSS.generate(seed, t) # OverflowError if t >= 2**32
partial_secshares_from_me = vss.secshares(n)
pop = pop_prove(vss.secret().to_bytes(), idx)
com = vss.commit()
com_to_secret = com.commitment_to_secret()
msg = ParticipantMsg(com, pop)
state = ParticipantState(t, n, idx, com_to_secret)
return state, msg, partial_secshares_from_me
# Helper function to prepare the secshare for participant idx's
# participant_step2() by summing the partial_secshares returned by all
# participants' participant_step1().
#
# In a pure run of SimplPedPop where secret shares are sent via external secure
# channels (i.e., EncPedPop is not used), each participant needs to run this
# function in preparation of their participant_step2(). Since this computation
# involves secret data, it cannot be delegated to the coordinator as opposed to
# other aggregation steps.
#
# If EncPedPop is used instead (as a wrapper of SimplPedPop), the coordinator
# can securely aggregate the encrypted partial secshares into an encrypted
# secshare by exploiting the additively homomorphic property of the encryption.
def participant_step2_prepare_secshare(
partial_secshares: List[Scalar],
) -> Scalar:
secshare: Scalar # REVIEW Work around missing type annotation of Scalar.sum
secshare = Scalar.sum(*partial_secshares)
return secshare
def participant_step2(
state: ParticipantState,
cmsg: CoordinatorMsg,
secshare: Scalar,
) -> Tuple[DKGOutput, bytes]:
t, n, idx, com_to_secret = state
coms_to_secrets, sum_coms_to_nonconst_terms, pops = cmsg
if (
len(coms_to_secrets) != n
or len(sum_coms_to_nonconst_terms) != t - 1
or len(pops) != n
):
raise ValueError
if coms_to_secrets[idx] != com_to_secret:
raise FaultyCoordinatorError(
"Coordinator sent unexpected first group element for local index"
)
for i in range(n):
if i == idx:
# No need to check our own pop.
continue
if coms_to_secrets[i].infinity:
raise FaultyParticipantOrCoordinatorError(
i, "Participant sent invalid commitment"
)
# This can be optimized: We serialize the coms_to_secrets[i] here, but
# schnorr_verify (inside pop_verify) will need to deserialize it again, which
# involves computing a square root to obtain the y coordinate.
if not pop_verify(pops[i], coms_to_secrets[i].to_bytes_xonly(), i):
raise FaultyParticipantOrCoordinatorError(
i, "Participant sent invalid proof-of-knowledge"
)
sum_coms = assemble_sum_coms(coms_to_secrets, sum_coms_to_nonconst_terms)
# Verifying the tweaked secshare against the tweaked pubshare is equivalent
# to verifying the untweaked secshare against the untweaked pubshare, but
# avoids computing the untweaked pubshare in the happy path and thereby
# moves a group addition to the error path.
sum_coms_tweaked, tweak, pubtweak = sum_coms.invalid_taproot_commit()
pubshare_tweaked = sum_coms_tweaked.pubshare(idx)
secshare_tweaked = secshare + tweak
if not VSSCommitment.verify_secshare(secshare_tweaked, pubshare_tweaked):
pubshare = pubshare_tweaked - pubtweak
raise UnknownFaultyParticipantOrCoordinatorError(
ParticipantInvestigationData(n, idx, secshare, pubshare),
"Received invalid secshare, "
"consider investigation procedure to determine faulty party",
)
threshold_pubkey = sum_coms_tweaked.commitment_to_secret()
pubshares = [
sum_coms_tweaked.pubshare(i)
if i != idx
else pubshare_tweaked # We have computed our own pubshare already.
for i in range(n)
]
dkg_output = DKGOutput(
secshare_tweaked.to_bytes(),
threshold_pubkey.to_bytes_compressed(),
[pubshare.to_bytes_compressed() for pubshare in pubshares],
)
eq_input = t.to_bytes(4, byteorder="big") + sum_coms.to_bytes()
return dkg_output, eq_input
def participant_investigate(
error: UnknownFaultyParticipantOrCoordinatorError,
cinv: CoordinatorInvestigationMsg,
partial_secshares: List[Scalar],
) -> NoReturn:
n, idx, secshare, pubshare = error.inv_data
if len(partial_secshares) != n:
raise ValueError
partial_pubshares = cinv.partial_pubshares
if GE.sum(*partial_pubshares) != pubshare:
raise FaultyCoordinatorError("Sum of partial pubshares not equal to pubshare")
if Scalar.sum(*partial_secshares) != secshare:
raise SecshareSumError("Sum of partial secshares not equal to secshare")
for i in range(n):
if not VSSCommitment.verify_secshare(
partial_secshares[i], partial_pubshares[i]
):
if i != idx:
raise FaultyParticipantOrCoordinatorError(
i, "Participant sent invalid partial secshare"
)
else:
# We are not faulty, so the coordinator must be.
raise FaultyCoordinatorError(
"Coordinator fiddled with the share from me to myself"
)
# We now know:
# - The sum of the partial secshares is equal to the secshare.
# - The sum of the partial pubshares is equal to the pubshare.
# - Every partial secshare matches its corresponding partial pubshare.
# Hence, the secshare matches the pubshare.
assert VSSCommitment.verify_secshare(secshare, pubshare)
# This should never happen (unless the caller fiddled with the inputs).
raise RuntimeError(
"participant_investigate() was called, but all inputs are consistent."
)
###
### Coordinator
###
def coordinator_step(
pmsgs: List[ParticipantMsg], t: int, n: int
) -> Tuple[CoordinatorMsg, DKGOutput, bytes]:
if len(pmsgs) != n:
raise ValueError
# Sum the commitments to the i-th coefficients for i > 0
#
# This procedure corresponds to the one described by Pedersen in Section 5.1
# of "Non-Interactive and Information-Theoretic Secure Verifiable Secret
# Sharing". However, we don't sum the commitments to the secrets (i == 0)
# because they'll be necessary to check the pops.
coms_to_secrets = [pmsg.com.commitment_to_secret() for pmsg in pmsgs]
# But we can sum the commitments to the non-constant terms.
sum_coms_to_nonconst_terms = [
GE.sum(*(pmsg.com.commitment_to_nonconst_terms()[j] for pmsg in pmsgs))
for j in range(t - 1)
]
pops = [pmsg.pop for pmsg in pmsgs]
cmsg = CoordinatorMsg(coms_to_secrets, sum_coms_to_nonconst_terms, pops)
sum_coms = assemble_sum_coms(coms_to_secrets, sum_coms_to_nonconst_terms)
sum_coms_tweaked, _, _ = sum_coms.invalid_taproot_commit()
threshold_pubkey = sum_coms_tweaked.commitment_to_secret()
pubshares = [sum_coms_tweaked.pubshare(i) for i in range(n)]
dkg_output = DKGOutput(
None,
threshold_pubkey.to_bytes_compressed(),
[pubshare.to_bytes_compressed() for pubshare in pubshares],
)
eq_input = t.to_bytes(4, byteorder="big") + sum_coms.to_bytes()
return cmsg, dkg_output, eq_input
def coordinator_investigate(
pmsgs: List[ParticipantMsg],
) -> List[CoordinatorInvestigationMsg]:
n = len(pmsgs)
all_partial_pubshares = [[pmsg.com.pubshare(i) for pmsg in pmsgs] for i in range(n)]
return [CoordinatorInvestigationMsg(all_partial_pubshares[i]) for i in range(n)]

103
src/jmfrost/chilldkg_ref/util.py

@ -0,0 +1,103 @@
from typing import Any
from ..secp256k1lab.util import tagged_hash
BIP_TAG = "BIP DKG/"
def tagged_hash_bip_dkg(tag: str, msg: bytes) -> bytes:
return tagged_hash(BIP_TAG + tag, msg)
class ProtocolError(Exception):
"""Base exception for errors caused by received protocol messages."""
class FaultyParticipantError(ProtocolError):
"""Raised if a participant is faulty.
This exception is raised by the coordinator code when it detects faulty
behavior by a participant, i.e., a participant has deviated from the
protocol. The index of the participant is provided as part of the exception.
Assuming protocol messages have been transmitted correctly and the
coordinator itself is not faulty, this exception implies that the
participant is indeed faulty.
This exception is raised only by the coordinator code. Some faulty behavior
by participants will be detected by the other participants instead.
See `FaultyParticipantOrCoordinatorError` for details.
Attributes:
participant (int): Index of the faulty participant.
"""
def __init__(self, participant: int, *args: Any):
self.participant = participant
super().__init__(participant, *args)
class FaultyParticipantOrCoordinatorError(ProtocolError):
"""Raised if another known participant or the coordinator is faulty.
This exception is raised by the participant code when it detects what looks
like faulty behavior by a suspected participant. The index of the suspected
participant is provided as part of the exception.
Importantly, this exception is not proof that the suspected participant is
indeed faulty. It is instead possible that the coordinator has deviated from
the protocol in a way that makes it look as if the suspected participant has
deviated from the protocol. In other words, assuming messages have been
transmitted correctly and the raising participant is not faulty, this
exception implies that
- the suspected participant is faulty,
- *or* the coordinator is faulty (and has framed the suspected
participant).
This exception is raised only by the participant code. Some faulty behavior
by participants will be detected by the coordinator instead. See
`FaultyParticipantError` for details.
Attributes:
participant (int): Index of the suspected participant.
"""
def __init__(self, participant: int, *args: Any):
self.participant = participant
super().__init__(participant, *args)
class FaultyCoordinatorError(ProtocolError):
"""Raised if the coordinator is faulty.
This exception is raised by the participant code when it detects faulty
behavior by the coordinator, i.e., the coordinator has deviated from the
protocol. Assuming protocol messages have been transmitted correctly and the
raising participant is not faulty, this exception implies that the
coordinator is indeed faulty.
"""
class UnknownFaultyParticipantOrCoordinatorError(ProtocolError):
"""Raised if another unknown participant or the coordinator is faulty.
This exception is raised by the participant code when it detects what looks
like faulty behavior by some other participant, but there is insufficient
information to determine which participant should be suspected.
To determine a suspected participant, the raising participant may choose to
run the optional investigation procedure of the protocol, which requires
obtaining an investigation message from the coordinator. See the
`participant_investigate` function for details.
This is only raised for specific faulty behavior by another participant
which cannot be attributed to another participant without further help of
the coordinator (namely, sending invalid encrypted secret shares).
Attributes:
inv_data: Information required to perform the investigation.
"""
def __init__(self, inv_data: Any, *args: Any):
self.inv_data = inv_data
super().__init__(*args)

146
src/jmfrost/chilldkg_ref/vss.py

@ -0,0 +1,146 @@
from __future__ import annotations
from typing import List, Tuple
from ..secp256k1lab.secp256k1 import GE, G, Scalar
from ..secp256k1lab.util import tagged_hash
from .util import tagged_hash_bip_dkg
class Polynomial:
# A scalar polynomial.
#
# A polynomial f of degree at most t - 1 is represented by a list `coeffs`
# of t coefficients, i.e., f(x) = coeffs[0] + ... + coeffs[t-1] *
# x^(t-1)."""
coeffs: List[Scalar]
def __init__(self, coeffs: List[Scalar]) -> None:
self.coeffs = coeffs
def eval(self, x: Scalar) -> Scalar:
# Evaluate a polynomial at position x.
value = Scalar(0)
# Reverse coefficients to compute evaluation via Horner's method
for coeff in self.coeffs[::-1]:
value = value * x + coeff
return value
def __call__(self, x: Scalar) -> Scalar:
return self.eval(x)
class VSSCommitment:
ges: List[GE]
def __init__(self, ges: List[GE]) -> None:
self.ges = ges
def t(self) -> int:
return len(self.ges)
def pubshare(self, i: int) -> GE:
pubshare: GE = GE.batch_mul(
*(((i + 1) ** j, self.ges[j]) for j in range(0, len(self.ges)))
)
return pubshare
@staticmethod
def verify_secshare(secshare: Scalar, pubshare: GE) -> bool:
# The caller needs to provide the correct pubshare(i)
actual = secshare * G
valid: bool = actual == pubshare
return valid
def to_bytes(self) -> bytes:
# Return commitments to the coefficients of f.
return b"".join([ge.to_bytes_compressed_with_infinity() for ge in self.ges])
def __add__(self, other: VSSCommitment) -> VSSCommitment:
assert self.t() == other.t()
return VSSCommitment([self.ges[i] + other.ges[i] for i in range(self.t())])
@staticmethod
def from_bytes_and_t(b: bytes, t: int) -> VSSCommitment:
if len(b) != 33 * t:
raise ValueError
ges = [GE.from_bytes_compressed(b[i : i + 33]) for i in range(0, 33 * t, 33)]
return VSSCommitment(ges)
def commitment_to_secret(self) -> GE:
return self.ges[0]
def commitment_to_nonconst_terms(self) -> List[GE]:
return self.ges[1 : self.t()]
def invalid_taproot_commit(self) -> Tuple[VSSCommitment, Scalar, GE]:
# Return a modified VSS commitment such that the threshold public key
# generated from it has an unspendable BIP 341 Taproot script path.
#
# Specifically, for a VSS commitment `com`, we have:
# `com.invalid_taproot_commit().commitment_to_secret() = com.commitment_to_secret() + t*G`.
#
# The tweak `t` commits to an empty message, which is invalid according
# to BIP 341 for Taproot script spends. This follows BIP 341's
# recommended approach for committing to an unspendable script path.
#
# This prevents a malicious participant from secretly inserting a *valid*
# Taproot commitment to a script path into the summed VSS commitment during
# the DKG protocol. If the resulting threshold public key was used directly
# in a BIP 341 Taproot output, the malicious participant would be able to
# spend the output using their hidden script path.
#
# The function returns the updated VSS commitment and the tweak `t` which
# must be added to all secret shares of the commitment.
pk = self.commitment_to_secret()
secshare_tweak = Scalar.from_bytes_checked(
tagged_hash("TapTweak", pk.to_bytes_compressed())
)
pubshare_tweak = secshare_tweak * G
vss_tweak = VSSCommitment([pubshare_tweak] + [GE()] * (self.t() - 1))
return (self + vss_tweak, secshare_tweak, pubshare_tweak)
class VSS:
f: Polynomial
def __init__(self, f: Polynomial) -> None:
self.f = f
@staticmethod
def generate(seed: bytes, t: int) -> VSS:
coeffs = [
Scalar.from_bytes_checked(
tagged_hash_bip_dkg("vss coeffs", seed + i.to_bytes(4, byteorder="big"))
)
for i in range(t)
]
return VSS(Polynomial(coeffs))
def secshare_for(self, i: int) -> Scalar:
# Return the secret share for the participant with index i.
#
# This computes f(i+1).
if i < 0:
raise ValueError(f"Invalid participant index: {i}")
x = Scalar(i + 1)
# Ensure we don't compute f(0), which is the secret.
assert x != Scalar(0)
return self.f(x)
def secshares(self, n: int) -> List[Scalar]:
# Return the secret shares for the participants with indices 0..n-1.
#
# This computes [f(1), ..., f(n)].
return [self.secshare_for(i) for i in range(0, n)]
def commit(self) -> VSSCommitment:
return VSSCommitment([c * G for c in self.f.coeffs])
def secret(self) -> Scalar:
# Return the secret to be shared.
#
# This computes f(0).
return self.f.coeffs[0]

751
src/jmfrost/frost_ref/README.md

@ -0,0 +1,751 @@
```
BIP:
Title: FROST Signing for BIP340-compatible Threshold Signatures
Author: Sivaram Dhakshinamoorthy <siv2ram@gmail.com>
Status: Draft
License: CC0-1.0
License-Code: MIT
Type: Informational
Created:
Post-History:
Comments-URI:
```
# FROST Signing for BIP340-compatible Threshold Signatures
### Abstract
This document proposes a standard for the Flexible Round-Optimized Schnorr Threshold (FROST) signing protocol. The standard is compatible with [BIP340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) public keys and signatures. It supports _tweaking_, which allows deriving [BIP32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) child keys from the group public key and creating [BIP341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki) Taproot outputs with key and script paths.
### Copyright
This document is licensed under the 3-clause BSD license.
## Introduction
This document proposes the FROST signing protocol based on the FROST3 variant (see section 2.3) introduced in ROAST[[RRJSS22](https://eprint.iacr.org/2022/550)], instead of the original FROST[[KG20](https://eprint.iacr.org/2020/852)]. Key generation for FROST signing is out of scope for this document. However, we specify the requirements that a key generation method must satisfy to be compatible with this signing protocol.
Many sections of this document have been directly copied or modified from [BIP327](https://github.com/bitcoin/bips/blob/master/bip-0327.mediawiki) due to the similarities between the FROST3 and [MuSig2](https://eprint.iacr.org/2020/1261.pdf) signature schemes.
### Motivation
The FROST signature scheme [[KG20](https://eprint.iacr.org/2020/852),[CKM21](https://eprint.iacr.org/2021/1375),[BTZ21](https://eprint.iacr.org/2022/833),[CGRS23](https://eprint.iacr.org/2023/899)] enables _t-of-n_ Schnorr threshold signatures, in which a threshold _t_ of some set of _n_ signers is required to produce a signature.
FROST remains unforgeable as long as at most _t-1_ signers are compromised, and remains functional as long as _t_ honest signers do not lose their secret key material. It supports any choice of _t_ as long as _1 ≤ t ≤ n_.[^t-edge-cases]
The primary motivation is to create a standard that allows users of different software projects to jointly control Taproot outputs ([BIP341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki)).
Such an output contains a public key which, in this case, would be the group public key derived from the public shares of threshold signers.
It can be spent using FROST to produce a signature for the key-based spending path.
The on-chain footprint of a FROST Taproot output is essentially a single BIP340 public key, and a transaction spending the output only requires a single signature cooperatively produced by _threshold_ signers. This is **more compact** and has **lower verification cost** than signers providing _n_ individual public keys and _t_ signatures, as would be required by an _t-of-n_ policy implemented using <code>OP_CHECKSIGADD</code> as introduced in ([BIP342](https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki)).
As a side effect, the numbers _t_ and _n_ of signers are not limited by any consensus rules when using FROST.
Moreover, FROST offers a **higher level of privacy** than <code>OP_CHECKSIGADD</code>: FROST Taproot outputs are indistinguishable for a blockchain observer from regular, single-signer Taproot outputs even though they are actually controlled by multiple signers. By tweaking a group public key, the shared Taproot output can have script spending paths that are hidden unless used.
There are threshold-signature schemes other than FROST that are fully compatible with Schnorr signatures.
The FROST variant proposed below stands out by combining all the following features:
* **Two Communication Rounds**: FROST is faster in practice than other threshold-signature schemes [[GJKR03](https://link.springer.com/chapter/10.1007/3-540-36563-x_26)] which requires at least three rounds, particularly when signers are connected through high-latency anonymous links. Moreover, the need for fewer communication rounds simplifies the algorithms and reduces the probability that implementations and users make security-relevant mistakes.
* **Efficiency over Robustness**: FROST trades off the robustness property for network efficiency (fewer communication rounds), requiring the protocol to be aborted in the case of any misbehaving participant.
* **Provable security**: FROST3 with an idealized key generation (i.e., trusted setup) has been [proven existentially unforgeable](https://eprint.iacr.org/2022/550.pdf) under the one-more discrete logarithm (OMDL) assumption (instead of the discrete logarithm assumption required for single-signer Schnorr signatures) in the random oracle model (ROM).
### Design
* **Compatibility with BIP340**: The group public key and participant public shares produced by a compatible key generation algorithm MUST be _plain_ public keys in compressed format. In this proposal, the signature output at the end of the signing protocol is a BIP340 signature, which passes BIP340 verification for the BIP340 X-only version of the group public key and a message.
* **Tweaking for BIP32 derivations and Taproot**: This proposal supports tweaking group public key and signing for this tweaked group public key. We distinguish two modes of tweaking: _Plain_ tweaking can be used to derive child group public keys per [BIP32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki)._X-only_ tweaking, on the other hand, allows creating a [BIP341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki) tweak to add script paths to a Taproot output. See [tweaking the group public key](./README.md#tweaking-group-public-key) below for details.
* **Non-interactive signing with preprocessing**: The first communication round, exchanging the nonces, can happen before the message or the exact set of signers is determined. Once the parameters of the signing session are finalized, the signers can send partial signatures without additional interaction.
* **Partial signature independent of order**: The output of the signing algorithm remains consistent regardless of the order in which participant identifiers and public shares are used during the session context initialization. This property is inherent when combining Shamir shares to derive any value.
* **Third-party nonce and partial signature aggregation**: Instead of every signer sending their nonce and partial signature to every other signer, it is possible to use an untrusted third-party _aggregator_ in order to reduce the communication complexity from quadratic to linear in the number of signers. In each of the two rounds, the aggregator collects all signers' contributions (nonces or partial signatures), aggregates them, and broadcasts the aggregate back to the signers. A malicious aggregator can force the signing session to fail to produce a valid Schnorr signature but cannot negatively affect the unforgeability of the scheme.
* **Partial signature verification**: If any signer sends a partial signature contribution that was not created by honestly following the signing protocol, the signing session will fail to produce a valid Schnorr signature. This proposal specifies a partial signature verification algorithm to identify disruptive signers. It is incompatible with third-party nonce aggregation because the individual nonce is required for partial verification.
* **Size of the nonce**: In the FROST3 variant, each signer's nonce consists of two elliptic curve points.
## Overview
Implementers must make sure to understand this section thoroughly to avoid subtle mistakes that may lead to catastrophic failure.
### Optionality of Features
The goal of this proposal is to support a wide range of possible application scenarios.
Given a specific application scenario, some features may be unnecessary or not desirable, and implementers can choose not to support them.
Such optional features include:
- Applying plain tweaks after x-only tweaks.
- Applying tweaks at all.
- Dealing with messages that are not exactly 32 bytes.
- Identifying a disruptive signer after aborting (aborting itself remains mandatory).
If applicable, the corresponding algorithms should simply fail when encountering inputs unsupported by a particular implementation. (For example, the signing algorithm may fail when given a message which is not 32 bytes.)
Similarly, the test vectors that exercise the unimplemented features should be re-interpreted to expect an error, or be skipped if appropriate.
### Key Generation
We distinguish between two public key types, namely _plain public keys_, the key type traditionally used in Bitcoin, and _X-only public keys_.
Plain public keys are byte strings of length 33 (often called _compressed_ format).
In contrast, X-only public keys are 32-byte strings defined in [BIP340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki).
FROST generates signatures that are verifiable as if produced by a single signer using a secret key _s_ with the corresponding public key. As a threshold signing protocol, the group secret key _s_ is shared among all _MAX_PARTICIPANTS_ participants using Shamir's secret sharing, and at least _MIN_PARTICIPANTS_ participants must collaborate to issue a valid signature.
&emsp;&ensp;_MIN_PARTICIPANTS_ is a positive non-zero integer lesser than or equal to _MAX_PARTICIPANTS_
&emsp;&ensp;_MAX_PARTICIPANTS_ MUST be a positive integer less than 2^32.
In particular, FROST signing assumes each participant is configured with the following information:
- An identifier _id_, which is an integer in the range _[0, MAX_PARTICIPANTS-1]_ and MUST be distinct from the identifier of every other participant.
<!-- REVIEW we haven't introduced participant identifier yet. So, don't use them here -->
- A secret share _secshare<sub>id</sub>_, which is a positive non-zero integer less than the secp256k1 curve order. This value represents the _i_-th Shamir secret share of the group secret key _s_. In particular, _secshare<sub>id</sub>_ is the value _f(id+1)_ on a secret polynomial _f_ of degree _(MIN_PARTICIPANTS - 1)_, where _s_ is _f(0)_.
- A Group public key _group_pk_, which is point on the secp256k1 curve.
- A public share _pubshare<sub>id</sub>_, which is point on the secp256k1 curve.
> [!NOTE]
> The definitions for the secp256k1 curve and its order can be found in the [Notation section](./README.md#notation).
As key generation for FROST signing is beyond the scope of this document, we do not specify how this information is configured and distributed to the participants. Generally, there are two possible key generation mechanisms: one involves a single, trusted dealer (see Appendix D of [FROST RFC draft](https://datatracker.ietf.org/doc/draft-irtf-cfrg-frost/15/)), and the other requires performing a distributed key generation protocol (see [BIP FROST DKG draft](https://github.com/BlockstreamResearch/bip-frost-dkg)).
For a key generation mechanism to be compatible with FROST signing, the participant information it generates MUST successfully pass both the _ValidateGroupPubkey_ and _ValidatePubshares_ functions (see [Key Generation Compatibility](./README.md#key-generation-compatibility)).
> [!IMPORTANT]
> It should be noted that while passing these functions ensures functional compatibility, it does not guarantee the security of the key generation mechanism.
### General Signing Flow
FROST signing is designed to be executed by a predetermined number of signer participants, referred to as _NUM_PARTICIPANTS_. This value is a positive non-zero integer that MUST be at least _MIN_PARTICIPANTS_ and MUST NOT exceed _MAX_PARTICIPANTS_. Therefore, the selection of signing participants from the participant group must be performed outside the signing protocol, prior to its initiation.
Whenever the signing participants want to sign a message, the basic order of operations to create a threshold-signature is as follows:
**First broadcast round:**
The signers start the signing session by running _NonceGen_ to compute _secnonce_ and _pubnonce_.[^nonce-serialization-detail]
Then, the signers broadcast their _pubnonce_ to each other and run _NonceAgg_ to compute an aggregate nonce.
**Second broadcast round:**
At this point, every signer has the required data to sign, which, in the algorithms specified below, is stored in a data structure called [Session Context](./README.md#session-context).
Every signer computes a partial signature by running _Sign_ with the participant identifier, the secret share, the _secnonce_ and the session context.
Then, the signers broadcast their partial signatures to each other and run _PartialSigAgg_ to obtain the final signature.
If all signers behaved honestly, the result passes [BIP340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) verification.
Both broadcast rounds can be optimized by using an aggregator who collects all signers' nonces or partial signatures, aggregates them using _NonceAgg_ or _PartialSigAgg_, respectively, and broadcasts the aggregate result back to the signers. A malicious aggregator can force the signing session to fail to produce a valid Schnorr signature but cannot negatively affect the unforgeability of the scheme, i.e., even a malicious aggregator colluding with all but one signer cannot forge a signature.
> [!IMPORTANT]
> The _Sign_ algorithm must **not** be executed twice with the same _secnonce_.
> Otherwise, it is possible to extract the secret signing key from the two partial signatures output by the two executions of _Sign_.
> To avoid accidental reuse of _secnonce_, an implementation may securely erase the _secnonce_ argument by overwriting it with 64 zero bytes after it has been read by _Sign_.
> A _secnonce_ consisting of only zero bytes is invalid for _Sign_ and will cause it to fail.
To simplify the specification of the algorithms, some intermediary values are unnecessarily recomputed from scratch, e.g., when executing _GetSessionValues_ multiple times.
Actual implementations can cache these values.
As a result, the [Session Context](./README.md#session-context) may look very different in implementations or may not exist at all.
However, computation of _GetSessionValues_ and storage of the result must be protected against modification from an untrusted third party.
This party would have complete control over the aggregate public key and message to be signed.
### Nonce Generation
> [!IMPORTANT]
> _NonceGen_ must have access to a high-quality random generator to draw an unbiased, uniformly random value _rand'_.
> In contrast to BIP340 signing, the values _k<sub>1</sub>_ and _k<sub>2</sub>_ **must not be derived deterministically** from the session parameters because deriving nonces deterministically allows for a [complete key-recovery attack in multi-party discrete logarithm-based signatures](https://medium.com/blockstream/musig-dn-schnorr-multisignatures-with-verifiably-deterministic-nonces-27424b5df9d6#e3b6).
The optional arguments to _NonceGen_ enable a defense-in-depth mechanism that may prevent secret share exposure if _rand'_ is accidentally not drawn uniformly at random.
If the value _rand'_ was identical in two _NonceGen_ invocations, but any other argument was different, the _secnonce_ would still be guaranteed to be different as well (with overwhelming probability), and thus accidentally using the same _secnonce_ for _Sign_ in both sessions would be avoided.
Therefore, it is recommended to provide the optional arguments _secshare_, _pubshare_, _group_pk_, and _m_ if these session parameters are already determined during nonce generation.
The auxiliary input _extra_in_ can contain additional contextual data that has a chance of changing between _NonceGen_ runs,
e.g., a supposedly unique session id (taken from the application), a session counter wide enough not to repeat in practice, any nonces by other signers (if already known), or the serialization of a data structure containing multiple of the above.
However, the protection provided by the optional arguments should only be viewed as a last resort.
In most conceivable scenarios, the assumption that the arguments are different between two executions of _NonceGen_ is relatively strong, particularly when facing an active adversary.
In some applications, it is beneficial to generate and send a _pubnonce_ before the other signers, their _pubshare_, or the message to sign is known.
In this case, only the available arguments are provided to the _NonceGen_ algorithm.
After this preprocessing phase, the _Sign_ algorithm can be run immediately when the message and set of signers is determined.
This way, the final signature is created quicker and with fewer round trips.
However, applications that use this method presumably store the nonces for a longer time and must therefore be even more careful not to reuse them.
Moreover, this method is not compatible with the defense-in-depth mechanism described in the previous paragraph.
Instead of every signer broadcasting their _pubnonce_ to every other signer, the signers can send their _pubnonce_ to a single aggregator node that runs _NonceAgg_ and sends the _aggnonce_ back to the signers.
This technique reduces the overall communication.
A malicious aggregator can force the signing session to fail to produce a valid Schnorr signature but cannot negatively affect the unforgeability of the scheme.
In general, FROST signers are stateful in the sense that they first generate _secnonce_ and then need to store it until they receive the other signers' _pubnonces_ or the _aggnonce_.
However, it is possible for one of the signers to be stateless.
This signer waits until it receives the _pubnonce_ of all the other signers and until session parameters such as a message to sign, participant identifiers, participant public shares, and tweaks are determined.
Then, the signer can run _NonceGen_, _NonceAgg_ and _Sign_ in sequence and send out its _pubnonce_ along with its partial signature.
Stateless signers may want to consider signing deterministically (see [Modifications to Nonce Generation](./README.md#modifications-to-nonce-generation)) to remove the reliance on the random number generator in the _NonceGen_ algorithm.
### Identifying Disruptive Signers
The signing protocol makes it possible to identify malicious signers who send invalid contributions to a signing session in order to make the signing session abort and prevent the honest signers from obtaining a valid signature.
This property is called "identifiable aborts" and ensures that honest parties can assign blame to malicious signers who cause an abort in the signing protocol.
Aborts are identifiable for an honest party if the following conditions hold in a signing session:
- The contributions received from all signers have not been tampered with (e.g., because they were sent over authenticated connections).
- Nonce aggregation is performed honestly (e.g., because the honest signer performs nonce aggregation on its own or because the aggregator is trusted).
- The partial signatures received from all signers are verified using the algorithm _PartialSigVerify_.
If these conditions hold and an honest party (signer or aggregator) runs an algorithm that fails due to invalid protocol contributions from malicious signers, then the algorithm run by the honest party will output the participant identifier of exactly one malicious signer.
Additionally, if the honest parties agree on the contributions sent by all signers in the signing session, all the honest parties who run the aborting algorithm will identify the same malicious signer.
#### Further Remarks
Some of the algorithms specified below may also assign blame to a malicious aggregator.
While this is possible for some particular misbehavior of the aggregator, it is not guaranteed that a malicious aggregator can be identified.
More specifically, a malicious aggregator (whose existence violates the second condition above) can always make signing abort and wrongly hold honest signers accountable for the abort (e.g., by claiming to have received an invalid contribution from a particular honest signer).
The only purpose of the algorithm _PartialSigVerify_ is to ensure identifiable aborts, and it is not necessary to use it when identifiable aborts are not desired.
In particular, partial signatures are _not_ signatures.
An adversary can forge a partial signature, i.e., create a partial signature without knowing the secret share for that particular participant public share.[^partialsig-forgery]
However, if _PartialSigVerify_ succeeds for all partial signatures then _PartialSigAgg_ will return a valid Schnorr signature.
### Tweaking the Group Public Key
The group public key can be _tweaked_, which modifies the key as defined in the [Tweaking Definition](./README.md#tweaking-definition) subsection.
In order to apply a tweak, the Tweak Context output by _TweakCtxInit_ is provided to the _ApplyTweak_ algorithm with the _is_xonly_t_ argument set to false for plain tweaking and true for X-only tweaking.
The resulting Tweak Context can be used to apply another tweak with _ApplyTweak_ or obtain the group public key with _GetXonlyPubkey_ or _GetPlainPubkey_.
The purpose of supporting tweaking is to ensure compatibility with existing uses of tweaking, i.e., that the result of signing is a valid signature for the tweaked public key.
The FROST signing algorithms take arbitrary tweaks as input but accepting arbitrary tweaks may negatively affect the security of the scheme.[^arbitrary-tweaks]
Instead, signers should obtain the tweaks according to other specifications.
This typically involves deriving the tweaks from a hash of the aggregate public key and some other information.
Depending on the specific scheme that is used for tweaking, either the plain or the X-only aggregate public key is required.
For example, to do [BIP32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) derivation, you call _GetPlainPubkey_ to be able to compute the tweak, whereas [BIP341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki) TapTweaks require X-only public keys that are obtained with _GetXonlyPubkey_.
The tweak mode provided to _ApplyTweak_ depends on the application:
Plain tweaking can be used to derive child public keys from an aggregate public key using [BIP32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki).
On the other hand, X-only tweaking is required for Taproot tweaking per [BIP341](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki).
A Taproot-tweaked public key commits to a _script path_, allowing users to create transaction outputs that are spendable either with a FROST threshold-signature or by providing inputs that satisfy the script path.
Script path spends require a control block that contains a parity bit for the tweaked X-only public key.
The bit can be obtained with _GetPlainPubkey(tweak_ctx)[0] & 1_.
## Algorithms
The following specification of the algorithms has been written with a focus on clarity. As a result, the specified algorithms are not always optimal in terms of computation and space. In particular, some values are recomputed but can be cached in actual implementations (see [General Signing Flow](./README.md#general-signing-flow)).
### Notation
The following conventions are used, with constants as defined for [secp256k1](https://www.secg.org/sec2-v2.pdf). We note that adapting this proposal to other elliptic curves is not straightforward and can result in an insecure scheme.
- Lowercase variables represent integers or byte arrays.
- The constant _p_ refers to the field size, _0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F_.
- The constant _n_ refers to the curve order, _0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141_.
- The constant _num_participants_ refers to number of participants involved in the signing process, must be at least _min_participants_ but must not be larger than _max_participants_.
- Uppercase variables refer to points on the curve with equation _y<sup>2</sup> = x<sup>3</sup> + 7_ over the integers modulo _p_.
- _is_infinite(P)_ returns whether _P_ is the point at infinity.
- _x(P)_ and _y(P)_ are integers in the range _0..p-1_ and refer to the X and Y coordinates of a point _P_ (assuming it is not infinity).
- The constant _G_ refers to the base point, for which _x(G) = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798_ and _y(G) = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8_.
- Addition of points refers to the usual [elliptic curve group operation](https://en.wikipedia.org/wiki/Elliptic_curve#The_group_law).
- [Multiplication (⋅) of an integer and a point](https://en.wikipedia.org/wiki/Elliptic_curve_point_multiplication) refers to the repeated application of the group operation.
- Functions and operations:
- _||_ refers to byte array concatenation.
- The function _x[i:j]_, where _x_ is a byte array and _i, j ≥ 0_, returns a _(j - i)_-byte array with a copy of the _i_th byte (inclusive) to the _j_th byte (exclusive) of _x_.
- The function _bytes(n, x)_, where _x_ is an integer, returns the n-byte encoding of _x_, most significant byte first.
- The constant _empty_bytestring_ refers to the empty byte array. It holds that _len(empty_bytestring) = 0_.
- The function _xbytes(P)_, where _P_ is a point for which _not is_infinite(P)_, returns _bytes(32, x(P))_.
- The function _len(x)_ where _x_ is a byte array returns the length of the array.
- The function _has_even_y(P)_, where _P_ is a point for which _not is_infinite(P)_, returns _y(P) mod 2 == 0_.
- The function _with_even_y(P)_, where _P_ is a point, returns _P_ if _is_infinite(P)_ or _has_even_y(P)_. Otherwise, _with_even_y(P)_ returns _-P_.
- The function _cbytes(P)_, where _P_ is a point for which _not is_infinite(P)_, returns _a || xbytes(P)_ where _a_ is a byte that is _2_ if _has_even_y(P)_ and _3_ otherwise.
- The function _cbytes_ext(P)_, where _P_ is a point, returns _bytes(33, 0)_ if _is_infinite(P)_. Otherwise, it returns _cbytes(P)_.
- The function _int(x)_, where _x_ is a 32-byte array, returns the 256-bit unsigned integer whose most significant byte first encoding is _x_.
- The function _lift_x(x)_, where _x_ is an integer in range _0..2<sup>256</sup>-1_, returns the point _P_ for which _x(P) = x_[^liftx-soln] and _has_even_y(P)_, or fails if _x_ is greater than _p-1_ or no such point exists. The function _lift_x(x)_ is equivalent to the following pseudocode:
- Fail if _x > p-1_.
- Let _c = x<sup>3</sup> + 7 mod p_.
- Let _y' = c<sup>(p+1)/4</sup> mod p_.
- Fail if _c ≠ y'<sup>2</sup> mod p_.
- Let _y = y'_ if _y' mod 2 = 0_, otherwise let _y = p - y'_ .
- Return the unique point _P_ such that _x(P) = x_ and _y(P) = y_.
- The function _cpoint(x)_, where _x_ is a 33-byte array (compressed serialization), sets _P = lift_x(int(x[1:33]))_ and fails if that fails. If _x[0] = 2_ it returns _P_ and if _x[0] = 3_ it returns _-P_. Otherwise, it fails.
- The function _cpoint_ext(x)_, where _x_ is a 33-byte array (compressed serialization), returns the point at infinity if _x = bytes(33, 0)_. Otherwise, it returns _cpoint(x)_ and fails if that fails.
- The function _hash<sub>tag</sub>(x)_ where _tag_ is a UTF-8 encoded tag name and _x_ is a byte array returns the 32-byte hash _SHA256(SHA256(tag) || SHA256(tag) || x)_.
- The function _count(lst, x)_, where _lst_ is a list of integers containing _x_, returns the number of times _x_ appears in _lst_.
- The function _has_unique_elements(lst)_, where _lst_ is a list of integers, returns True if _count(lst, x)_ returns 1 for all _x_ in _lst_. Otherwise returns False. The function _has_unique_elements(lst)_ is equivalent to the following pseudocode:
- For _x_ in _lst_:
- if _count(lst, x)_ > 1:
- Return False
- Return True
- The function _sorted(lst)_, where _lst_ is a list of integers, returns a new list of integers in ascending order.
- Other:
- Tuples are written by listing the elements within parentheses and separated by commas. For example, _(2, 3, 1)_ is a tuple.
### Key Generation Compatibility
Internal Algorithm _PlainPubkeyGen(sk):_[^pubkey-gen-ecdsa]
- Input:
- The secret key _sk_: a 32-byte array, freshly generated uniformly at random
- Let _d' = int(sk)_.
- Fail if _d' = 0_ or _d' &ge; n_.
- Return _cbytes(d'⋅G)_.
<!-- REVIEW maybe write scripts to automate these italics (math symbols)? -->
Algorithm _ValidatePubshares(secshare<sub>1..u</sub>, pubshare<sub>1..u</sub>)_
- Inputs:
- The number _u_ of participants involved in keygen: an integer equal to _max_participants_
- The participant secret shares _secshare<sub>1..u</sub>_: _u_ 32-byte arrays
- The corresponding public shares _pubshare<sub>1..u</sub>_: _u_ 33-byte arrays
- For _i = 1 .. u_:
- Fail if _PlainPubkeyGen(secshare<sub>i</sub>)_ ≠ _pubshare<sub>i</sub>_
- Return success iff no failure occurred before reaching this point.
Algorithm _ValidateGroupPubkey(threshold, group_pk, id<sub>1..u</sub>, pubshare<sub>1..u</sub>)_:
- Inputs:
- The number _u_ of participants involved in keygen: an integer equal to _max_participants_
- The number _threshold_ of participants required to issue a signature: an integer equal to _min_participants_
- The group public key _group_pk_: a 33-byte array
- The participant identifiers _id<sub>1..u</sub>_: _u_ integers, each with 0 ≤ _id<sub>i</sub>_ < _max_participants_
- The participant public shares _pubshares<sub>1..u</sub>_: _u_ 33-byte arrays
- Fail if _threshold_ > _u_
- For _t_ = _threshold..u_:
- For each combination of _t_ elements from _id<sub>1..u</sub>_:[^itertools-combinations]
- Let _signer_id<sub>1..t</sub>_ be the current combination of participant identifiers
- Let _signer_pubshare<sub>1..t</sub>_ be their corresponding participant pubshares[^calc-signer-pubshares]
- _expected_pk_ = _DeriveGroupPubkey(signer_id<sub>1..t</sub>, signer_pubshare<sub>1..t</sub>)_
- Fail if _group_pk_ ≠ _expected_pk_
- Return success iff no failure occurred before reaching this point.
### Key Derivation and Tweaking
#### Tweak Context
The Tweak Context is a data structure consisting of the following elements:
- The point _Q_ representing the potentially tweaked group public key: an elliptic curve point
- The accumulated tweak _tacc_: an integer with _0 ≤ tacc < n_
- The value _gacc_: 1 or -1 mod n
We write "Let _(Q, gacc, tacc) = tweak_ctx_" to assign names to the elements of a Tweak Context.
Algorithm _TweakCtxInit(id<sub>1..u</sub>, pubshare<sub>1..u</sub>):_
- Input:
- The number _u_ of participants available in the signing session with _min_participants ≤ u ≤ max_participants_
- The participant identifiers _id<sub>1..u</sub>_: _u_ integers, each with 0 ≤ _id<sub>i</sub>_ < _max_participants_
- The individual public shares _pubshare<sub>1..u</sub>_: _u_ 33-byte arrays
- Let _group_pk = DeriveGroupPubkey(id<sub>1..u</sub>, pubshare<sub>1..u</sub>)_; fail if that fails
- Let _Q = cpoint(group_pk)_
- Fail if _is_infinite(Q)_.
- Let _gacc = 1_
- Let _tacc = 0_
- Return _tweak_ctx = (Q, gacc, tacc)_.
Internal Algorithm _DeriveGroupPubkey(id<sub>1..u</sub>, pubshare<sub>1..u</sub>)_
- _inf_point = bytes(33, 0)_
- _Q_ = _cpoint_ext(inf_point)_
- For _i_ = _1..u_:
- _P_ = _cpoint(pubshare<sub>i</sub>)_; fail if that fails
- _lambda_ = _DeriveInterpolatingValue(id<sub>1..u</sub>, id<sub>i</sub>)_
- _Q_ = _Q_ + _lambda⋅P_
- Return _cbytes(Q)_
Internal Algorithm _DeriveInterpolatingValue(id<sub>1..u</sub>, my_id):_
- Fail if _my_id_ not in _id<sub>1..u</sub>_
- Fail if not _has_unique_elements(id<sub>1..u</sub>)
- Let _num = 1_
- Let _denom = 1_
- For _i = 1..u_:
- If _id<sub>i</sub>_ ≠ _my_id_:
- Let _num_ = _num⋅(id<sub>i</sub>_ + 1)
- Let _denom_ = _denom⋅(id<sub>i</sub> - my_id)_
- _lambda_ = _num⋅denom<sup>-1</sup> mod n_
- Return _lambda_
Algorithm _GetXonlyPubkey(tweak_ctx)_:
- Let _(Q, _, _) = tweak_ctx_
- Return _xbytes(Q)_
Algorithm _GetPlainPubkey(tweak_ctx)_:
- Let _(Q, _, _) = tweak_ctx_
- Return _cbytes(Q)_
#### Applying Tweaks
Algorithm _ApplyTweak(tweak_ctx, tweak, is_xonly_t)_:
- Inputs:
- The _tweak_ctx_: a [Tweak Context](./README.md#tweak-context) data structure
- The _tweak_: a 32-byte array
- The tweak mode _is_xonly_t_: a boolean
- Let _(Q, gacc, tacc) = tweak_ctx_
- If _is_xonly_t_ and _not has_even_y(Q)_:
- Let _g = -1 mod n_
- Else:
- Let _g = 1_
- Let _t = int(tweak)_; fail if _t ≥ n_
- Let _Q' = g⋅Q + t⋅G_
- Fail if _is_infinite(Q')_
- Let _gacc' = g⋅gacc mod n_
- Let _tacc' = t + g⋅tacc mod n_
- Return _tweak_ctx' = (Q', gacc', tacc')_
### Nonce Generation
Algorithm _NonceGen(secshare, pubshare, group_pk, m, extra_in)_:
- Inputs:
- The participant’s secret share _secshare_: a 32-byte array (optional argument)
- The corresponding public share _pubshare_: a 33-byte array (optional argument)
- The x-only group public key _group_pk_: a 32-byte array (optional argument)
- The message _m_: a byte array (optional argument)[^max-msg-len]
- The auxiliary input _extra_in_: a byte array with _0 ≤ len(extra_in) ≤ 2<sup>32</sup>-1_ (optional argument)
- Let _rand'_ be a 32-byte array freshly drawn uniformly at random
- If the optional argument _secshare_ is present:
- Let _rand_ be the byte-wise xor of _secshare_ and _hash<sub>FROST/aux</sub>(rand')_[^sk-xor-rand]
- Else:
- Let _rand = rand'_
- If the optional argument _pubshare_ is not present:
- Let _pubshare_ = _empty_bytestring_
- If the optional argument _group_pk_ is not present:
- Let _group_pk_ = _empty_bytestring_
- If the optional argument _m_ is not present:
- Let _m_prefixed = bytes(1, 0)_
- Else:
- Let _m_prefixed = bytes(1, 1) || bytes(8, len(m)) || m_
- If the optional argument _extra_in_ is not present:
- Let _extra_in = empty_bytestring_
- Let _k<sub>i</sub> = int(hash<sub>FROST/nonce</sub>(rand || bytes(1, len(pubshare)) || pubshare || bytes(1, len(group_pk)) || group_pk || m_prefixed || bytes(4, len(extra_in)) || extra_in || bytes(1, i - 1))) mod n_ for _i = 1,2_
- Fail if _k<sub>1</sub> = 0_ or _k<sub>2</sub> = 0_
- Let _R<sub>⁎,1</sub> = k<sub>1</sub>⋅G, R<sub>⁎,2</sub> = k<sub>2</sub>⋅G_
- Let _pubnonce = cbytes(R<sub>,1</sub>) || cbytes(R<sub>⁎,2</sub>)_
- Let _secnonce = bytes(32, k<sub>1</sub>) || bytes(32, k<sub>2</sub>)_[^secnonce-ser]
- Return _(secnonce, pubnonce)_
### Nonce Aggregation
Algorithm _NonceAgg(pubnonce<sub>1..u</sub>, id<sub>1..u</sub>)_:
- Inputs:
- The number of signers _u_: an integer with _min_participants ≤ u ≤ max_participants_
- The public nonces _pubnonce<sub>1..u</sub>_: _u_ 66-byte arrays
- The participant identifiers _id<sub>1..u</sub>_: _u_ integers, each with 0 ≤ _id<sub>i</sub>_ < _max_participants_
- For _j = 1 .. 2_:
- For _i = 1 .. u_:
- Let _R<sub>i,j</sub> = cpoint(pubnonce<sub>i</sub>[(j-1)_33:j_33])_; fail if that fails and blame signer _id<sub>i</sub>_ for invalid _pubnonce_.
- Let _R<sub>j</sub> = R<sub>1,j</sub> + R<sub>2,j</sub> + ... + R<sub>u,j</sub>_
- Return _aggnonce = cbytes_ext(R<sub>1</sub>) || cbytes_ext(R<sub>2</sub>)_
### Session Context
The Session Context is a data structure consisting of the following elements:
- The number _u_ of participants available in the signing session with _min_participants ≤ u ≤ max_participants_
- The participant identifiers _id<sub>1..u</sub>_: _u_ integers, each with 0 ≤ _id<sub>i</sub>_ < _max_participants_
- The individual public shares _pubshare<sub>1..u</sub>_: _u_ 33-byte arrays
- The aggregate public nonce of signers _aggnonce_: a 66-byte array
- The number _v_ of tweaks with _0 ≤ v < 2^32_
- The tweaks _tweak<sub>1..v</sub>_: _v_ 32-byte arrays
- The tweak modes _is_xonly_t<sub>1..v</sub>_ : _v_ booleans
- The message _m_: a byte array[^max-msg-len]
We write "Let _(u, id<sub>1..u</sub>, pubshare<sub>1..u</sub>, aggnonce, v, tweak<sub>1..v</sub>, is_xonly_t<sub>1..v</sub>, m) = session_ctx_" to assign names to the elements of a Session Context.
Algorithm _GetSessionValues(session_ctx)_:
- Let _(u, id<sub>1..u</sub>, pubshare<sub>1..u</sub>, aggnonce, v, tweak<sub>1..v</sub>, is_xonly_t<sub>1..v</sub>, m) = session_ctx_
- Let _tweak_ctx<sub>0</sub> = TweakCtxInit(id<sub>1..u</sub>, pubshare<sub>1..u</sub>)_; fail if that fails
- For _i = 1 .. v_:
- Let _tweak_ctx<sub>i</sub> = ApplyTweak(tweak_ctx<sub>i-1</sub>, tweak<sub>i</sub>, is_xonly_t<sub>i</sub>)_; fail if that fails
- Let _(Q, gacc, tacc) = tweak_ctx<sub>v</sub>_
- Let _ser_ids_ = _SerializeIds(id<sub>1..u</sub>)_
- Let b = _int(hash<sub>FROST/noncecoef</sub>(ser_ids || aggnonce || xbytes(Q) || m)) mod n_
- Let _R<sub>1</sub> = cpoint_ext(aggnonce[0:33]), R<sub>2</sub> = cpoint_ext(aggnonce[33:66])_; fail if that fails and blame nonce aggregator for invalid _aggnonce_.
- Let _R' = R<sub>1</sub> + b⋅R<sub>2</sub>_
- If _is_infinite(R'):_
- _Let final nonce_ R = G _([see Dealing with Infinity in Nonce Aggregation](./README.md#dealing-with-infinity-in-nonce-aggregation))_
- _Else:_
- _Let final nonce_ R = R'
- Let _e = int(hash<sub>BIP0340/challenge</sub>((xbytes(R) || xbytes(Q) || m))) mod n_
- _Return_ (Q, gacc, tacc, b, R, e)
<!-- REVIEW should we check for duplicates and id value range here? -->
Internal Algorithm _SerializeIds(id<sub>1..u</sub>)_:
- _res = empty_bytestring_
- For _id_ in _sorted(id<sub>1..u</sub>)_:
- _res = res || bytes(4, id)_
- Return _res_
Algorithm _GetSessionInterpolatingValue(session_ctx, my_id)_:
- Let _(u, id<sub>1..u</sub>, _, _, _, _, _) = session_ctx_
- Return _DeriveInterpolatingValue(id<sub>1..u</sub>, my_id)_; fail if that fails
Algorithm _SessionHasSignerPubshare(session_ctx, signer_pubshare)_:
- Let _(u, _, pubshare<sub>1..u</sub>, _, _, _, _) = session_ctx_
- If _signer_pubshare in pubshare<sub>1..u</sub>_
- Return True
- Otherwise Return False
### Signing
Algorithm _Sign(secnonce, secshare, my_id, session_ctx)_:
- Inputs:
- The secret nonce _secnonce_ that has never been used as input to _Sign_ before: a 64-byte array[^secnonce-ser]
- The secret signing key _secshare_: a 32-byte array
- The identifier of the signing participant _my_id_: an integer with 0 _≤ my_id < max_participants_
- The _session_ctx_: a [Session Context](./README.md#session-context) data structure
- Let _(Q, gacc, _, b, R, e) = GetSessionValues(session_ctx)_; fail if that fails
- Let _k<sub>1</sub>' = int(secnonce[0:32]), k<sub>2</sub>' = int(secnonce[32:64])_
- Fail if _k<sub>i</sub>' = 0_ or _k<sub>i</sub>' ≥ n_ for _i = 1..2_
- Let _k<sub>1</sub> = k<sub>1</sub>', k<sub>2</sub> = k<sub>2</sub>'_ if _has_even_y(R)_, otherwise let _k<sub>1</sub> = n - k<sub>1</sub>', k<sub>2</sub> = n - k<sub>2</sub>'_
- Let _d' = int(secshare)_
- Fail if _d' = 0_ or _d' ≥ n_
- Let _P = d'⋅G_
- Let _pubshare = cbytes(P)_
- Fail if _SessionHasSignerPubshare(session_ctx, pubshare) = False_
- Let _&lambda; = GetSessionInterpolatingValue(session_ctx, my_id)_; fail if that fails
- Let _g = 1_ if _has_even_y(Q)_, otherwise let _g = -1 mod n_
- Let _d = g⋅gacc⋅d' mod n_ (See [_Negation of Secret Share When Signing_](./README.md#negation-of-the-secret-share-when-signing))
- Let _s = (k<sub>1</sub> + b⋅k<sub>2</sub> + e⋅&lambda;⋅d) mod n_
- Let _psig = bytes(32, s)_
- Let _pubnonce = cbytes(k<sub>1</sub>'⋅G) || cbytes(k<sub>2</sub>'⋅G)_
- If _PartialSigVerifyInternal(psig, my_id, pubnonce, pubshare, session_ctx)_ (see below) returns failure, fail[^why-verify-partialsig]
- Return partial signature _psig_
### Partial Signature Verification
Algorithm _PartialSigVerify(psig, id<sub>1..u</sub>, pubnonce<sub>1..u</sub>, pubshare<sub>1..u</sub>, tweak<sub>1..v</sub>, is_xonly_t<sub>1..v</sub>, m, i)_:
- Inputs:
- The partial signature _psig_: a 32-byte array
- The number _u_ of identifiers, public nonces, and individual public shares with _min_participants ≤ u ≤ max_participants_
- The participant identifiers _id<sub>1..u</sub>_: _u_ integers, each with 0 ≤ _id<sub>i</sub>_ < _max_participants_
- The public nonces _pubnonce<sub>1..u</sub>_: _u_ 66-byte arrays
- The individual public shares _pubshare<sub>1..u</sub>_: _u_ 33-byte arrays
- The number _v_ of tweaks with _0 ≤ v < 2^32_
- The tweaks _tweak<sub>1..v</sub>_: _v_ 32-byte arrays
- The tweak modes _is_xonly_t<sub>1..v</sub>_ : _v_ booleans
- The message _m_: a byte array[^max-msg-len]
- The index _i_ of the signer in the list of identifiers, public nonces, and individual public shares where _0 < i u_
- Let _aggnonce = NonceAgg(pubnonce<sub>1..u</sub>)_; fail if that fails
- Let _session_ctx = (u, id<sub>1..u</sub>, pubshare<sub>1..u</sub>, aggnonce, v, tweak<sub>1..v</sub>, is_xonly_t<sub>1..v</sub>, m)_
- Run _PartialSigVerifyInternal(psig, id<sub>i</sub>, pubnonce<sub>i</sub>, pubshare<sub>i</sub>, session_ctx)_
- Return success iff no failure occurred before reaching this point.
Internal Algorithm _PartialSigVerifyInternal(psig, my_id, pubnonce, pubshare, session_ctx)_:
- Let _(Q, gacc, _, b, R, e) = GetSessionValues(session_ctx)_; fail if that fails
- Let _s = int(psig)_; fail if _s ≥ n_
- Fail if _SessionHasSignerPubshare(session_ctx, pubshare) = False_
- Let _R<sub>⁎,1</sub> = cpoint(pubnonce[0:33]), R<sub>⁎,2</sub> = cpoint(pubnonce[33:66])_
- Let _Re<sub></sub>' = R<sub>⁎,1</sub> + b⋅R<sub>⁎,2</sub>_
- Let effective nonce _Re<sub>⁎</sub> = Re<sub>⁎</sub>'_ if _has_even_y(R)_, otherwise let _Re<sub></sub> = -Re<sub></sub>'_
- Let _P = cpoint(pubshare)_; fail if that fails
- Let _&lambda; = GetSessionInterpolatingValue(session_ctx, my_id)_[^lambda-cant-fail]
- Let _g = 1_ if _has_even_y(Q)_, otherwise let _g = -1 mod n_
- Let _g' = g⋅gacc mod n_ (See [_Negation of Pubshare When Partially Verifying_](./README.md#negation-of-the-pubshare-when-partially-verifying))
- Fail if _s⋅G ≠ Re<sub></sub> + e⋅&lambda;⋅g'⋅P_
- Return success iff no failure occurred before reaching this point.
### Partial Signature Aggregation
Algorithm _PartialSigAgg(psig<sub>1..u</sub>, id<sub>1..u</sub>, session_ctx)_:
- Inputs:
- The number _u_ of signatures with _min_participants ≤ u ≤ max_participants_
- The partial signatures _psig<sub>1..u</sub>_: _u_ 32-byte arrays
- The participant identifiers _id<sub>1..u</sub>_: _u_ integers, each with 0 ≤ _id<sub>i</sub>_ < _max_participants_
- The _session_ctx_: a [Session Context](./README.md#session-context) data structure
- Let _(Q, _, tacc, _, _, R, e) = GetSessionValues(session_ctx)_; fail if that fails
- For _i = 1 .. u_:
- Let _s<sub>i</sub> = int(psig<sub>i</sub>)_; fail if _s<sub>i</sub> ≥ n_ and blame signer _id<sub>i</sub>_ for invalid partial signature.
- Let _g = 1_ if _has_even_y(Q)_, otherwise let _g = -1 mod n_
- Let _s = s<sub>1</sub> + ... + s<sub>u</sub> + e⋅g⋅tacc mod n_
- Return _sig =_ xbytes(R) || bytes(32, s)
### Test Vectors & Reference Code
We provide a naive, highly inefficient, and non-constant time [pure Python 3 reference implementation of the group public key tweaking, nonce generation, partial signing, and partial signature verification algorithms](./reference/reference.py).
Standalone JSON test vectors are also available in the [same directory](./reference/vectors/), to facilitate porting the test vectors into other implementations.
> [!CAUTION]
> The reference implementation is for demonstration purposes only and not to be used in production environments.
## Remarks on Security and Correctness
### Modifications to Nonce Generation
Implementers must avoid modifying the _NonceGen_ algorithm without being fully aware of the implications.
We provide two modifications to _NonceGen_ that are secure when applied correctly and may be useful in special circumstances, summarized in the following table.
| | needs secure randomness | needs secure counter | needs to keep state securely | needs aggregate nonce of all other signers (only possible for one signer) |
| --- | --- | --- | --- | --- |
| **NonceGen** | ✓ | | ✓ | |
| **CounterNonceGen** | | ✓ | ✓ | |
| **DeterministicSign** | | | | ✓ |
First, on systems where obtaining uniformly random values is much harder than maintaining a global atomic counter, it can be beneficial to modify _NonceGen_.
The resulting algorithm _CounterNonceGen_ does not draw _rand'_ uniformly at random but instead sets _rand'_ to the value of an atomic counter that is incremented whenever it is read.
With this modification, the secret share _secshare_ of the signer generating the nonce is **not** an optional argument and must be provided to _NonceGen_.
The security of the resulting scheme then depends on the requirement that reading the counter must never yield the same counter value in two _NonceGen_ invocations with the same _secshare_.
Second, if there is a unique signer who is supposed to send the _pubnonce_ last, it is possible to modify nonce generation for this single signer to not require high-quality randomness.
Such a nonce generation algorithm _DeterministicSign_ is specified below.
Note that the only optional argument is _rand_, which can be omitted if randomness is entirely unavailable.
_DeterministicSign_ requires the argument _aggothernonce_ which should be set to the output of _NonceAgg_ run on the _pubnonce_ value of **all** other signers (but can be provided by an untrusted party).
Hence, using _DeterministicSign_ is only possible for the last signer to generate a nonce and makes the signer stateless, similar to the stateless signer described in the [Nonce Generation](./README.md#nonce-generation) section.
<!-- REVIEW just say max_participants is < 2^32 during intro, than mentioning it everywhere -->
#### Deterministic and Stateless Signing for a Single Signer
Algorithm _DeterministicSign(secshare, my_id, aggothernonce, id<sub>1..u</sub>, pubshare<sub>1..u</sub>, tweak<sub>1..v</sub>, is_xonly_t<sub>1..v</sub>, m, rand)_:
- Inputs:
- The secret share _secshare_: a 32-byte array
- The identifier of the signing participant _my_id_: an integer with 0 _≤ my_id < max_participants_
- The aggregate public nonce _aggothernonce_ (see [above](./README.md#modifications-to-nonce-generation)): a 66-byte array
- The number _u_ of identifiers and participant public shares with _min_participants ≤ u ≤ max_participants_
- The participant identifiers _id<sub>1..u</sub>_: _u_ integers, each with 0 ≤ _id<sub>i</sub>_ < _max_participants_
- The individual public shares _pubshare<sub>1..u</sub>_: _u_ 33-byte arrays
- The number _v_ of tweaks with _0 &le; v < 2^32_
- The tweaks _tweak<sub>1..v</sub>_: _v_ 32-byte arrays
- The tweak methods _is_xonly_t<sub>1..v</sub>_: _v_ booleans
- The message _m_: a byte array[^max-msg-len]
- The auxiliary randomness _rand_: a 32-byte array (optional argument)
- If the optional argument _rand_ is present:
- Let _secshare'_ be the byte-wise xor of _secshare_ and _hash<sub>FROST/aux</sub>(rand)_
- Else:
- Let _secshare' = secshare_
- Let _tweak_ctx<sub>0</sub> = TweakCtxInit(id<sub>1..u</sub>, pubshare<sub>1..u</sub>)_; fail if that fails
- For _i = 1 .. v_:
- Let _tweak_ctx<sub>i</sub> = ApplyTweak(tweak_ctx<sub>i-1</sub>, tweak<sub>i</sub>, is_xonly_t<sub>i</sub>)_; fail if that fails
- Let _tweaked_gpk = GetXonlyPubkey(tweak_ctx<sub>v</sub>)_
- Let _k<sub>i</sub> = int(hash<sub>FROST/deterministic/nonce</sub>(secshare' || aggothernonce || tweaked_gpk || bytes(8, len(m)) || m || bytes(1, i - 1))) mod n_ for _i = 1,2_
- Fail if _k<sub>1</sub> = 0_ or _k<sub>2</sub> = 0_
- Let _R<sub>⁎,1</sub> = k<sub>1</sub>⋅G, R<sub>⁎,2</sub> = k<sub>2</sub>⋅G_
- Let _pubnonce = cbytes(R<sub>⁎,2</sub>) || cbytes(R<sub>⁎,2</sub>)_
- Let _d = int(secshare)_
- Fail if _d = 0_ or _d &ge; n_
- Let _signer_pubshare = cbytes(d⋅G)_
- Fail if _signer_pubshare_ is not present in _pubshare<sub>1..u</sub>_
- Let _secnonce = bytes(32, k<sub>1</sub>) || bytes(32, k<sub>2</sub>)_
- Let _aggnonce = NonceAgg((pubnonce, aggothernonce))_; fail if that fails and blame nonce aggregator for invalid _aggothernonce_.
- Let _session_ctx = (u, id<sub>1..u</sub>, pubshare<sub>1..u</sub>, aggnonce, v, tweak<sub>1..v</sub>, is_xonly_t<sub>1..v</sub>, m)_
- Return _(pubnonce, Sign(secnonce, secshare, my_id, session_ctx))_
### Tweaking Definition
Two modes of tweaking the group public key are supported. They correspond to the following algorithms:
Algorithm _ApplyPlainTweak(P, t)_:
- Inputs:
- _P_: a point
- The tweak _t_: an integer with _0 ≤ t < n_
- Return _P + t⋅G_
Algorithm _ApplyXonlyTweak(P, t)_:
- Return _with_even_y(P) + t⋅G_
### Negation of the Secret Share when Signing
During the signing process, the *[Sign](./README.md#signing)* algorithm might have to negate the secret share in order to produce a partial signature for an X-only group public key. This public key is derived from *u* public shares and *u* participant identifiers (denoted by the signer set *U*) and then tweaked *v* times (X-only or plain).
The following elliptic curve points arise as intermediate steps when creating a signature:
_P<sub>i</sub>_ as computed in any compatible key generation method is the point corresponding to the *i*-th signer's public share. Defining *d<sub>i</sub>'* to be the *i*-th signer's secret share as an integer, i.e., the *d’* value as computed in the *Sign* algorithm of the *i*-th signer, we have:
&emsp;&ensp;*P<sub>i</sub> = d<sub>i</sub>'⋅G*
*Q<sub>0</sub>* is the group public key derived from the signer’s public shares. It is identical to the value *Q* computed in *DeriveGroupPubkey* and therefore defined as:
&emsp;&ensp;_Q<sub>0</sub> = &lambda;<sub>1, U</sub>⋅P<sub>1</sub> + &lambda;<sub>2, U</sub>⋅P<sub>2</sub> + ... + &lambda;<sub>u, U</sub>⋅P<sub>u</sub>_
*Q<sub>i</sub>* is the tweaked group public key after the *i*-th execution of *ApplyTweak* for *1 ≤ i ≤ v*. It holds that
&emsp;&ensp;*Q<sub>i</sub> = f(i-1) + t<sub>i</sub>⋅G* for *i = 1, ..., v* where
&emsp;&ensp;&emsp;&ensp;*f(i-1) := with_even_y(Q<sub>i-1</sub>)* if *is_xonly_t<sub>i</sub>* and
&emsp;&ensp;&emsp;&ensp;*f(i-1) := Q<sub>i-1</sub>* otherwise.
*with_even_y(Q*<sub>v</sub>*)* is the final result of the group public key derivation and tweaking operations. It corresponds to the output of *GetXonlyPubkey* applied on the final Tweak Context.
The signer's goal is to produce a partial signature corresponding to the final result of group pubkey derivation and tweaking, i.e., the X-only public key *with_even_y(Q<sub>v</sub>)*.
For _1 ≤ i ≤ v_, we denote the value _g_ computed in the _i_-th execution of _ApplyTweak_ by _g<sub>i-1</sub>_. Therefore, _g<sub>i-1</sub>_ is _-1 mod n_ if and only if _is_xonly_t<sub>i</sub>_ is true and _Q<sub>i-1</sub>_ has an odd Y coordinate. In other words, _g<sub>i-1</sub>_ indicates whether _Q<sub>i-1</sub>_ needed to be negated to apply an X-only tweak:
&emsp;&ensp;_f(i-1) = g<sub>i-1</sub>⋅Q<sub>i-1</sub>_ for _1 ≤ i ≤ v_.
Furthermore, the _Sign_ and _PartialSigVerify_ algorithms set value _g_ depending on whether Q<sub>v</sub> needed to be negated to produce the (X-only) final output. For consistency, this value _g_ is referred to as _g<sub>v</sub>_ in this section.
&emsp;&ensp;_with_even_y(Q<sub>v</sub>) = g<sub>v</sub>⋅Q<sub>v</sub>_.
So, the (X-only) final public key is
&emsp;&ensp;_with_even_y(Q<sub>v</sub>)_
&emsp;&ensp;&emsp;&ensp;= _g<sub>v</sub>⋅Q<sub>v</sub>_
&emsp;&ensp;&emsp;&ensp;= _g<sub>v</sub>⋅(f(v-1)_ + _t<sub>v</sub>⋅G)_
&emsp;&ensp;&emsp;&ensp;= _g<sub>v</sub>⋅(g<sub>v-1</sub>⋅(f(v-2)_ + _t<sub>v-1</sub>⋅G)_ + _t<sub>v</sub>⋅G)_
&emsp;&ensp;&emsp;&ensp;= _g<sub>v</sub>⋅g<sub>v-1</sub>⋅f(v-2)_ + _g<sub>v</sub>⋅(t<sub>v</sub>_ + _g<sub>v-1</sub>⋅t<sub>v-1</sub>)⋅G_
&emsp;&ensp;&emsp;&ensp;= _g<sub>v</sub>⋅g<sub>v-1</sub>⋅f(v-2)_ + _(sum<sub>i=v-1..v</sub> t<sub>i</sub>⋅prod<sub>j=i..v</sub> g<sub>j</sub>)⋅G_
&emsp;&ensp;&emsp;&ensp;= _g<sub>v</sub>⋅g<sub>v-1</sub>⋅...⋅g<sub>1</sub>⋅f(0)_ + _(sum<sub>i=1..v</sub> t<sub>i</sub>⋅prod<sub>j=i..v</sub> g<sub>j</sub>)⋅G_
&emsp;&ensp;&emsp;&ensp;= _g<sub>v</sub>⋅...⋅g<sub>0</sub>⋅Q<sub>0</sub>_ + _g<sub>v</sub>⋅tacc<sub>v</sub>⋅G_
&emsp;&ensp;where _tacc<sub>i</sub>_ is computed by _TweakCtxInit_ and _ApplyTweak_ as follows:
&emsp;&ensp;&emsp;&ensp;_tacc<sub>0</sub>_ = _0_
&emsp;&ensp;&emsp;&ensp;_tacc<sub>i</sub>_ = _t<sub>i</sub>_ + _g<sub>i-1</sub>⋅tacc<sub>i-1</sub> for i=1..v mod n_
&emsp;&ensp;for which it holds that _g<sub>v</sub>⋅tacc<sub>v</sub>_ = _sum<sub>i=1..v</sub> t<sub>i</sub>⋅prod<sub>j=i..v</sub> g<sub>j</sub>_.
_TweakCtxInit_ and _ApplyTweak_ compute
&emsp;&ensp;_gacc<sub>0</sub>_ = 1
&emsp;&ensp;_gacc<sub>i</sub>_ = _g<sub>i-1</sub>⋅gacc<sub>i-1</sub> for i=1..v mod n_
So we can rewrite above equation for the final public key as
&emsp;&ensp;_with_even_y(Q<sub>v</sub>)_ = _g<sub>v</sub>⋅gacc<sub>v</sub>⋅Q<sub>0</sub>_ + _g<sub>v</sub>⋅tacc<sub>v</sub>⋅G._
Then we have
&emsp;&ensp;_with_even_y(Q<sub>v</sub>)_ - _g<sub>v</sub>⋅tacc<sub>v</sub>⋅G_
&emsp;&ensp;&emsp;&ensp;= _g<sub>v</sub>⋅gacc<sub>v</sub>⋅Q<sub>0</sub>_
&emsp;&ensp;&emsp;&ensp;= _g<sub>v</sub>⋅gacc<sub>v</sub>⋅(&lambda;<sub>1, U</sub>⋅P<sub>1</sub> + ... + &lambda;<sub>u, U</sub>⋅P<sub>u</sub>)_
&emsp;&ensp;&emsp;&ensp;= _g<sub>v</sub>⋅gacc<sub>v</sub>⋅(&lambda;<sub>1, U</sub>⋅d<sub>1</sub>'⋅G + ... + &lambda;<sub>u, U</sub>⋅d<sub>u</sub>'⋅G)_
&emsp;&ensp;&emsp;&ensp;= _sum<sub>i=1..u</sub>(g<sub>v</sub>⋅gacc<sub>v</sub>⋅&lambda;<sub>i, U</sub>⋅d<sub>i</sub>')*G._
Intuitively, _gacc<sub>i</sub>_ tracks accumulated sign flipping and _tacc<sub>i</sub>_ tracks the accumulated tweak value after applying the first _i_ individual tweaks. Additionally, _g<sub>v</sub>_ indicates whether _Q<sub>v</sub>_ needed to be negated to produce the final X-only result. Thus, signer _i_ multiplies its secret share _d<sub>i</sub>'_ with _g<sub>v</sub>⋅gacc<sub>v</sub>_ in the [_Sign_](./README.md#signing) algorithm.
#### Negation of the Pubshare when Partially Verifying
As explained in [Negation Of The Secret Share When Signing](./README.md#negation-of-the-secret-share-when-signing) the signer uses a possibly negated secret share
&emsp;&ensp;_d = g<sub>v</sub>⋅gacc<sub>v</sub>⋅d' mod n_
when producing a partial signature to ensure that the aggregate signature will correspond to a group public key with even Y coordinate.
The [_PartialSigVerifyInternal_](./README.md#partial-signature-verification) algorithm is supposed to check
&emsp;&ensp;_s⋅G = Re<sub></sub> + e⋅&lambda;⋅d⋅G_.
The verifier doesn't have access to _d⋅G_ but can construct it using the participant public share _pubshare_ as follows:
_d⋅G
&emsp;&ensp;= g<sub>v</sub>⋅gacc<sub>v</sub>⋅d'⋅G
&emsp;&ensp;= g<sub>v</sub>⋅gacc<sub>v</sub>⋅cpoint(pubshare)_
Note that the group public key and list of tweaks are inputs to partial signature verification, so the verifier can also construct _g<sub>v</sub>_ and _gacc<sub>v</sub>_.
### Dealing with Infinity in Nonce Aggregation
If the nonce aggregator provides _aggnonce = bytes(33,0) || bytes(33,0)_, either the nonce aggregator is dishonest or there is at least one dishonest signer (except with negligible probability).
If signing aborted in this case, it would be impossible to determine who is dishonest.
Therefore, signing continues so that the culprit is revealed when collecting and verifying partial signatures.
However, the final nonce _R_ of a BIP340 Schnorr signature cannot be the point at infinity.
If we would nonetheless allow the final nonce to be the point at infinity, then the scheme would lose the following property:
if _PartialSigVerify_ succeeds for all partial signatures, then _PartialSigAgg_ will return a valid Schnorr signature.
Since this is a valuable feature, we modify FROST3 (which is defined in the section 2.3 of the [ROAST paper](https://eprint.iacr.org/2022/550.pdf)) to avoid producing an invalid Schnorr signature while still allowing detection of the dishonest signer: In _GetSessionValues_, if the final nonce _R_ would be the point at infinity, set it to the generator instead (an arbitrary choice).
This modification to _GetSessionValues_ does not affect the unforgeability of the scheme.
Given a successful adversary against the unforgeability game (EUF-CMA) for the modified scheme, a reduction can win the unforgeability game for the original scheme by simulating the modification towards the adversary:
When the adversary provides _aggnonce' = bytes(33, 0) || bytes(33, 0)_, the reduction sets _aggnonce = cbytes_ext(G) || bytes(33, 0)_.
For any other _aggnonce'_, the reduction sets _aggnonce = aggnonce'_.
(The case that the adversary provides an _aggnonce' ≠ bytes(33, 0) || bytes(33, 0)_ but nevertheless _R'_ in _GetSessionValues_ is the point at infinity happens only with negligible probability.)
## Backwards Compatibility
This document proposes a standard for the FROST threshold signature scheme that is compatible with [BIP340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki). FROST is _not_ compatible with ECDSA signatures traditionally used in Bitcoin.
## Changelog
To help the reader understand updates to this document, we attach a version number that resembles "semantic versioning" (`MAJOR.MINOR.PATCH`).
The `MAJOR` version is incremented if changes to the BIP are introduced that are incompatible with prior versions.
An exception to this rule is `MAJOR` version zero (0.y.z) which is for development and does not need to be incremented if backwards-incompatible changes are introduced.
The `MINOR` version is incremented whenever the inputs or the output of an algorithm changes in a backward-compatible way or new backward-compatible functionality is added.
The `PATCH` version is incremented for other noteworthy changes (bug fixes, test vectors, important clarifications, etc.).
* *0.2.0* (2025-04-11): Includes minor fixes and the following major changes:
- Initialize `TweakCtxInit` using individual `pubshares` instead of the group public key.
- Add Python script to automate generation of test vectors.
- Represent participant identifiers as 4-byte integers in the range `0..MAX_PARTICIPANTS - 1` (inclusive).
* *0.1.0* (2024-07-31): Publication of draft BIP on the bitcoin-dev mailing list
## Acknowledgments
We thank Jonas Nick, Tim Ruffing, Jesse Posner, and Sebastian Falbesoner for their contributions to this document.
<!-- Footnotes -->
[^t-edge-cases]: While `t = n` and `t = 1` are in principle supported, simpler alternatives are available in these cases.
In the case `t = n`, using a dedicated `n`-of-`n` multi-signature scheme such as MuSig2 (see [BIP327](bip-0327.mediawiki)) instead of FROST avoids the need for an interactive DKG.
The case `t = 1` can be realized by letting one signer generate an ordinary [BIP340](bip-0340.mediawiki) key pair and transmitting the key pair to every other signer, who can check its consistency and then simply use the ordinary [BIP340](bip-0340.mediawiki) signing algorithm.
Signers still need to ensure that they agree on a key pair. A detailed specification for this key sharing protocol is not in the scope of this document.
[^nonce-serialization-detail]: We treat the _secnonce_ and _pubnonce_ as grammatically singular even though they include serializations of two scalars and two elliptic curve points, respectively. This treatment may be confusing for readers familiar with the MuSig2 paper. However, serialization is a technical detail that is irrelevant for users of MuSig2 interfaces.
[^pubkey-gen-ecdsa]: The _PlainPubkeyGen_ algorithm matches the key generation procedure traditionally used for ECDSA in Bitcoin
[^itertools-combinations]: This line represents a loop over every possible combination of `t` elements sourced from the `int_ids` array. This operation is equivalent to invoking the [`itertools.combinations(int_ids, t)`](https://docs.python.org/3/library/itertools.html#itertools.combinations) function call in Python.
[^calc-signer-pubshares]: This _signer_pubshare<sub>1..t</sub>_ list can be computed from the input _pubshare<sub>1..u</sub>_ list.
Method 1 - use `itertools.combinations(zip(int_ids, pubshares), t)`
Method 2 - For _i = 1..t_ : signer_pubshare<sub>i</sub> = pubshare<sub>signer_id<sub>i</sub></sub>
[^arbitrary-tweaks]: It is an open question whether allowing arbitrary tweaks from an adversary affects the unforgeability of FROST.
[^partialsig-forgery]: Assume a malicious participant intends to forge a partial signature for the participant with public share _P_. It participates in the signing session pretending to be two distinct signers: one with the public share _P_ and the other with its own public share. The adversary then sets the nonce for the second signer in such a way that allows it to generate a partial signature for _P_. As a side effect, it cannot generate a valid partial signature for its own public share. An explanation of the steps required to create a partial signature forgery can be found in [this document](docs/partialsig_forgery.md).
[^liftx-soln]: Given a candidate X coordinate _x_ in the range _0..p-1_, there exist either exactly two or exactly zero valid Y coordinates. If no valid Y coordinate exists, then _x_ is not a valid X coordinate either, i.e., no point _P_ exists for which _x(P) = x_. The valid Y coordinates for a given candidate _x_ are the square roots of _c = x<sup>3</sup> + 7 mod p_ and they can be computed as _y = &plusmn;c<sup>(p+1)/4</sup> mod p_ (see [Quadratic residue](https://en.wikipedia.org/wiki/Quadratic_residue#Prime_or_prime_power_modulus)) if they exist, which can be checked by squaring and comparing with _c_.
[^max-msg-len]: In theory, the allowed message size is restricted because SHA256 accepts byte strings only up to size of 2^61-1 bytes (and because of the 8-byte length encoding).
[^sk-xor-rand]: The random data is hashed (with a unique tag) as a precaution against situations where the randomness may be correlated with the secret signing key itself. It is xored with the secret key (rather than combined with it in a hash) to reduce the number of operations exposed to the actual secret key.
[^secnonce-ser]: The algorithms as specified here assume that the _secnonce_ is stored as a 64-byte array using the serialization _secnonce = bytes(32, k<sub>1</sub>) || bytes(32, k<sub>2</sub>)_. The same format is used in the reference implementation and in the test vectors. However, since the _secnonce_ is (obviously) not meant to be sent over the wire, compatibility between implementations is not a concern, and this method of storing the _secnonce_ is merely a suggestion.<br />
The _secnonce_ is effectively a local data structure of the signer which comprises the value triple _(k<sub>1</sub>, k<sub>2</sub>)_, and implementations may choose any suitable method to carry it from _NonceGen_ (first communication round) to _Sign_ (second communication round). In particular, implementations may choose to hide the _secnonce_ in internal state without exposing it in an API explicitly, e.g., in an effort to prevent callers from reusing a _secnonce_ accidentally.
[^why-verify-partialsig]: Verifying the signature before leaving the signer prevents random or adversarially provoked computation errors. This prevents publishing invalid signatures which may leak information about the secret key. It is recommended but can be omitted if the computation cost is prohibitive.
[^lambda-cant-fail]: _GetSessionInterpolatingValue(session_ctx, my_id)_ cannot fail when called from _PartialSigVerifyInternal_.

1
src/jmfrost/frost_ref/__init__.py

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

442
src/jmfrost/frost_ref/reference.py

@ -0,0 +1,442 @@
# -*- coding: utf-8 -*-
# BIP FROST Signing reference implementation
#
# It's worth noting that many functions, types, and exceptions were directly
# copied or modified from the MuSig2 (BIP 327) reference code, found at:
# https://github.com/bitcoin/bips/blob/master/bip-0327/reference.py
#
# WARNING: This implementation is for demonstration purposes only and _not_ to
# be used in production environments. The code is vulnerable to timing attacks,
# for example.
from typing import List, Optional, Tuple, NewType, NamedTuple, Sequence
import itertools
import secrets
from ..secp256k1lab.keys import pubkey_gen_plain
from ..secp256k1lab.secp256k1 import G, GE, Scalar
from ..secp256k1lab.util import int_from_bytes, tagged_hash
PlainPk = NewType('PlainPk', bytes)
XonlyPk = NewType('XonlyPk', bytes)
# There are two types of exceptions that can be raised by this implementation:
# - ValueError for indicating that an input doesn't conform to some function
# precondition (e.g. an input array is the wrong length, a serialized
# representation doesn't have the correct format).
# - InvalidContributionError for indicating that a signer (or the
# aggregator) is misbehaving in the protocol.
#
# Assertions are used to (1) satisfy the type-checking system, and (2) check for
# inconvenient events that can't happen except with negligible probability (e.g.
# output of a hash function is 0) and can't be manually triggered by any
# signer.
# This exception is raised if a party (signer or nonce aggregator) sends invalid
# values. Actual implementations should not crash when receiving invalid
# contributions. Instead, they should hold the offending party accountable.
class InvalidContributionError(Exception):
def __init__(self, signer_id, contrib):
# participant identifier of the signer who sent the invalid value
self.id = signer_id
# contrib is one of "pubkey", "pubnonce", "aggnonce", or "psig".
self.contrib = contrib
def xbytes(P: GE) -> bytes:
return P.to_bytes_xonly()
def cbytes(P: GE) -> bytes:
return P.to_bytes_compressed()
def cbytes_ext(P: GE) -> bytes:
if P.infinity:
return (0).to_bytes(33, byteorder='big')
return cbytes(P)
def cpoint(x: bytes) -> GE:
return GE.from_bytes_compressed(x)
def cpoint_ext(x: bytes) -> GE:
if x == (0).to_bytes(33, 'big'):
return GE()
else:
return cpoint(x)
# Return the plain public key corresponding to a given secret key
def individual_pk(seckey: bytes) -> PlainPk:
return PlainPk(pubkey_gen_plain(seckey))
# TODO: add my_id < max_participants check
def derive_interpolating_value(ids: List[int], my_id: int) -> Scalar:
if not my_id in ids:
raise ValueError('The signer\'s id must be present in the participant identifier list.')
if not all(ids.count(my_id) <= 1 for my_id in ids):
raise ValueError('The participant identifier list must contain unique elements.')
#todo: turn this into raise ValueError?
assert 0 <= my_id < 2**32
num, deno = 1, 1
for curr_id in ids:
if curr_id == my_id:
continue
num *= curr_id + 1
deno *= (curr_id - my_id)
return Scalar.from_int_wrapping(num * pow(deno, GE.ORDER - 2, GE.ORDER))
def check_pubshares_correctness(secshares: List[bytes], pubshares: List[PlainPk]) -> bool:
assert len(secshares) == len(pubshares)
for secshare, pubshare in zip(secshares, pubshares):
if not individual_pk(secshare) == pubshare:
return False
return True
def check_group_pubkey_correctness(min_participants: int, group_pk: PlainPk, ids: List[int], pubshares: List[PlainPk]) -> bool:
assert len(ids) == len(pubshares)
assert len(ids) >= min_participants
max_participants = len(ids)
# loop through all possible number of signers
for signer_count in range(min_participants, max_participants + 1):
# loop through all possible signer sets with length `signer_count`
for signer_set in itertools.combinations(zip(ids, pubshares), signer_count):
signer_ids = [pid for pid, pubshare in signer_set]
signer_pubshares = [pubshare for pid, pubshare in signer_set]
expected_pk = derive_group_pubkey(signer_pubshares, signer_ids)
if expected_pk != group_pk:
return False
return True
def check_frost_key_compatibility(max_participants: int, min_participants: int, group_pk: PlainPk, ids: List[int], secshares: List[bytes], pubshares: List[PlainPk]) -> bool:
if not max_participants >= min_participants > 1:
return False
if not len(ids) == len(secshares) == len(pubshares) == max_participants:
return False
pubshare_check = check_pubshares_correctness(secshares, pubshares)
group_pk_check = check_group_pubkey_correctness(min_participants, group_pk, ids, pubshares)
return pubshare_check and group_pk_check
TweakContext = NamedTuple('TweakContext', [('Q', GE),
('gacc', int),
('tacc', int)])
AGGREGATOR_ID = None
def get_xonly_pk(tweak_ctx: TweakContext) -> XonlyPk:
Q, _, _ = tweak_ctx
return XonlyPk(xbytes(Q))
def get_plain_pk(tweak_ctx: TweakContext) -> PlainPk:
Q, _, _ = tweak_ctx
return PlainPk(cbytes(Q))
#nit: switch the args ordering
def derive_group_pubkey(pubshares: List[PlainPk], ids: List[int]) -> PlainPk:
assert len(pubshares) == len(ids)
# assert AGGREGATOR_ID not in ids
Q = GE()
for my_id, pubshare in zip(ids, pubshares):
try:
X_i = cpoint(pubshare)
except ValueError:
raise InvalidContributionError(my_id, "pubshare")
lam_i = derive_interpolating_value(ids, my_id)
Q = Q + lam_i * X_i
# Q is not the point at infinity except with negligible probability.
assert not Q.infinity
return PlainPk(cbytes(Q))
def tweak_ctx_init(pubshares: List[PlainPk], ids: List[int]) -> TweakContext:
group_pk = derive_group_pubkey(pubshares, ids)
Q = cpoint(group_pk)
gacc = 1
tacc = 0
return TweakContext(Q, gacc, tacc)
def apply_tweak(tweak_ctx: TweakContext, tweak: bytes, is_xonly: bool) -> TweakContext:
if len(tweak) != 32:
raise ValueError('The tweak must be a 32-byte array.')
Q, gacc, tacc = tweak_ctx
if is_xonly and not Q.has_even_y():
g = Scalar(-1)
else:
g = Scalar(1)
try:
t = Scalar.from_bytes_checked(tweak)
except ValueError:
raise ValueError('The tweak must be less than n.')
Q_ = g * Q + t * G
if Q_.infinity:
raise ValueError('The result of tweaking cannot be infinity.')
gacc_ = g * gacc
tacc_ = t + g * tacc
return TweakContext(Q_, gacc_, tacc_)
def bytes_xor(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))
def nonce_hash(rand: bytes, pubshare: PlainPk, group_pk: XonlyPk, i: int, msg_prefixed: bytes, extra_in: bytes) -> int:
buf = b''
buf += rand
buf += len(pubshare).to_bytes(1, 'big')
buf += pubshare
buf += len(group_pk).to_bytes(1, 'big')
buf += group_pk
buf += msg_prefixed
buf += len(extra_in).to_bytes(4, 'big')
buf += extra_in
buf += i.to_bytes(1, 'big')
return int_from_bytes(tagged_hash('FROST/nonce', buf))
def nonce_gen_internal(rand_: bytes, secshare: Optional[bytes], pubshare: Optional[PlainPk], group_pk: Optional[XonlyPk], msg: Optional[bytes], extra_in: Optional[bytes]) -> Tuple[bytearray, bytes]:
if secshare is not None:
rand = bytes_xor(secshare, tagged_hash('FROST/aux', rand_))
else:
rand = rand_
if pubshare is None:
pubshare = PlainPk(b'')
if group_pk is None:
group_pk = XonlyPk(b'')
if msg is None:
msg_prefixed = b'\x00'
else:
msg_prefixed = b'\x01'
msg_prefixed += len(msg).to_bytes(8, 'big')
msg_prefixed += msg
if extra_in is None:
extra_in = b''
k_1 = Scalar.from_int_wrapping(nonce_hash(rand, pubshare, group_pk, 0, msg_prefixed, extra_in))
k_2 = Scalar.from_int_wrapping(nonce_hash(rand, pubshare, group_pk, 1, msg_prefixed, extra_in))
# k_1 == 0 or k_2 == 0 cannot occur except with negligible probability.
assert k_1 != 0
assert k_2 != 0
R_s1 = k_1 * G
R_s2 = k_2 * G
assert not R_s1.infinity
assert not R_s2.infinity
pubnonce = cbytes(R_s1) + cbytes(R_s2)
# use mutable `bytearray` since secnonce need to be replaced with zeros during signing.
secnonce = bytearray(k_1.to_bytes() + k_2.to_bytes())
return secnonce, pubnonce
#think: can msg & extra_in be of any length here?
#think: why doesn't musig2 ref code check for `pk` length here?
#REVIEW: Why should group_pk be XOnlyPk here? Shouldn't it be PlainPk?
def nonce_gen(secshare: Optional[bytes], pubshare: Optional[PlainPk], group_pk: Optional[XonlyPk], msg: Optional[bytes], extra_in: Optional[bytes]) -> Tuple[bytearray, bytes]:
if secshare is not None and len(secshare) != 32:
raise ValueError('The optional byte array secshare must have length 32.')
if pubshare is not None and len(pubshare) != 33:
raise ValueError('The optional byte array pubshare must have length 33.')
if group_pk is not None and len(group_pk) != 32:
raise ValueError('The optional byte array group_pk must have length 32.')
# bench: will adding individual_pk(secshare) == pubshare check, increase the execution time significantly?
rand_ = secrets.token_bytes(32)
return nonce_gen_internal(rand_, secshare, pubshare, group_pk, msg, extra_in)
# REVIEW should we raise value errors for:
# (1) duplicate ids
# (2) 0 <= id < max_participants < 2^32
# in each function that takes `ids` as argument?
# `ids` is typed as Sequence[Optional[int]] so that callers can pass either
# List[int] or List[Optional[int]] without triggering mypy invariance errors.
# Sequence is read-only and covariant.
def nonce_agg(pubnonces: List[bytes], ids: Sequence[Optional[int]]) -> bytes:
if len(pubnonces) != len(ids):
raise ValueError('The pubnonces and ids arrays must have the same length.')
aggnonce = b''
for j in (1, 2):
R_j = GE()
for my_id, pubnonce in zip(ids, pubnonces):
try:
R_ij = cpoint(pubnonce[(j-1)*33:j*33])
except ValueError:
raise InvalidContributionError(my_id, "pubnonce")
R_j = R_j + R_ij
aggnonce += cbytes_ext(R_j)
return aggnonce
SessionContext = NamedTuple('SessionContext', [('aggnonce', bytes),
('identifiers', List[int]),
('pubshares', List[PlainPk]),
('tweaks', List[bytes]),
('is_xonly', List[bool]),
('msg', bytes)])
def group_pubkey_and_tweak(pubshares: List[PlainPk], ids: List[int], tweaks: List[bytes], is_xonly: List[bool]) -> TweakContext:
if len(pubshares) != len(ids):
raise ValueError('The pubshares and ids arrays must have the same length.')
if len(tweaks) != len(is_xonly):
raise ValueError('The tweaks and is_xonly arrays must have the same length.')
tweak_ctx = tweak_ctx_init(pubshares, ids)
v = len(tweaks)
for i in range(v):
tweak_ctx = apply_tweak(tweak_ctx, tweaks[i], is_xonly[i])
return tweak_ctx
def get_session_values(session_ctx: SessionContext) -> Tuple[GE, int, int, Scalar, GE, Scalar]:
(aggnonce, ids, pubshares, tweaks, is_xonly, msg) = session_ctx
Q, gacc, tacc = group_pubkey_and_tweak(pubshares, ids, tweaks, is_xonly)
# sort the ids before serializing because ROAST paper considers them as a set
ser_ids = serialize_ids(ids)
b = Scalar.from_bytes_wrapping(tagged_hash('FROST/noncecoef', ser_ids + aggnonce + xbytes(Q) + msg))
try:
R_1 = cpoint_ext(aggnonce[0:33])
R_2 = cpoint_ext(aggnonce[33:66])
except ValueError:
# Nonce aggregator sent invalid nonces
raise InvalidContributionError(None, "aggnonce")
R_ = R_1 + b * R_2
R = R_ if not R_.infinity else G
assert not R.infinity
e = Scalar.from_bytes_wrapping(tagged_hash('BIP0340/challenge', xbytes(R) + xbytes(Q) + msg))
return (Q, gacc, tacc, b, R, e)
def serialize_ids(ids: List[int]) -> bytes:
# REVIEW assert for ids not being unsigned values?
sorted_ids = sorted(ids)
ser_ids = b''.join(
i.to_bytes(4, byteorder="big", signed=False) for i in sorted_ids
)
return ser_ids
def get_session_interpolating_value(session_ctx: SessionContext, my_id: int) -> Scalar:
(_, ids, _, _, _, _) = session_ctx
return derive_interpolating_value(ids, my_id)
def session_has_signer_pubshare(session_ctx: SessionContext, pubshare: bytes) -> bool:
(_, _, pubshares_list, _, _, _) = session_ctx
return pubshare in pubshares_list
def sign(secnonce: bytearray, secshare: bytes, my_id: int, session_ctx: SessionContext) -> bytes:
# do we really need the below check?
# add test vector for this check if confirmed
if not 0 <= my_id < 2**32:
raise ValueError('The signer\'s participant identifier is out of range')
(Q, gacc, _, b, R, e) = get_session_values(session_ctx)
# TODO: use `Scalar.from_bytes_nonzero_checked` for deserializing k1 and k2, once available
# in a secp256k1lab release (see https://github.com/secp256k1lab/secp256k1lab/pull/8)
try:
k_1_ = Scalar.from_bytes_checked(secnonce[0:32])
if k_1_ == 0: # treat zero exactly like any other bad input
raise ValueError
except ValueError:
raise ValueError('first secnonce value is out of range.')
try:
k_2_ = Scalar.from_bytes_checked(secnonce[32:64])
if k_2_ == 0: # treat zero exactly like any other bad input
raise ValueError
except ValueError:
raise ValueError('second secnonce value is out of range.')
# Overwrite the secnonce argument with zeros such that subsequent calls of
# sign with the same secnonce raise a ValueError.
secnonce[:] = bytearray(b'\x00'*64)
k_1 = k_1_ if R.has_even_y() else -k_1_
k_2 = k_2_ if R.has_even_y() else -k_2_
d_ = int_from_bytes(secshare)
if not 0 < d_ < GE.ORDER:
raise ValueError('The signer\'s secret share value is out of range.')
P = d_ * G
assert not P.infinity
pubshare = cbytes(P)
if not session_has_signer_pubshare(session_ctx, pubshare):
raise ValueError('The signer\'s pubshare must be included in the list of pubshares.')
a = get_session_interpolating_value(session_ctx, my_id)
g = Scalar(1) if Q.has_even_y() else Scalar(-1)
d = g * gacc * d_
s = k_1 + b * k_2 + e * a * d
psig = s.to_bytes()
R_s1 = k_1_ * G
R_s2 = k_2_ * G
assert not R_s1.infinity
assert not R_s2.infinity
pubnonce = cbytes(R_s1) + cbytes(R_s2)
# Optional correctness check. The result of signing should pass signature verification.
assert partial_sig_verify_internal(psig, my_id, pubnonce, pubshare, session_ctx)
return psig
# REVIEW should we hash the signer set (or pubshares) too? Otherwise same nonce will be generate even if the signer set changes
def det_nonce_hash(secshare_: bytes, aggothernonce: bytes, tweaked_gpk: bytes, msg: bytes, i: int) -> int:
buf = b''
buf += secshare_
buf += aggothernonce
buf += tweaked_gpk
buf += len(msg).to_bytes(8, 'big')
buf += msg
buf += i.to_bytes(1, 'big')
return int_from_bytes(tagged_hash('FROST/deterministic/nonce', buf))
def deterministic_sign(secshare: bytes, my_id: int, aggothernonce: bytes, ids: List[int], pubshares: List[PlainPk], tweaks: List[bytes], is_xonly: List[bool], msg: bytes, rand: Optional[bytes]) -> Tuple[bytes, bytes]:
if rand is not None:
secshare_ = bytes_xor(secshare, tagged_hash('FROST/aux', rand))
else:
secshare_ = secshare
tweaked_gpk = get_xonly_pk(group_pubkey_and_tweak(pubshares, ids, tweaks, is_xonly))
k_1 = Scalar.from_int_wrapping(det_nonce_hash(secshare_, aggothernonce, tweaked_gpk, msg, 0))
k_2 = Scalar.from_int_wrapping(det_nonce_hash(secshare_, aggothernonce, tweaked_gpk, msg, 1))
# k_1 == 0 or k_2 == 0 cannot occur except with negligible probability.
assert k_1 != 0
assert k_2 != 0
R_s1 = k_1 * G
R_s2 = k_2 * G
assert not R_s1.infinity
assert not R_s2.infinity
pubnonce = cbytes(R_s1) + cbytes(R_s2)
secnonce = bytearray(k_1.to_bytes() + k_2.to_bytes())
try:
aggnonce = nonce_agg([pubnonce, aggothernonce], [my_id, AGGREGATOR_ID])
except Exception:
# Since `pubnonce` can never be invalid, blame aggregator's pubnonce.
# REVIEW: should we introduce an unknown participant or aggregator error?
raise InvalidContributionError(AGGREGATOR_ID, "aggothernonce")
session_ctx = SessionContext(aggnonce, ids, pubshares, tweaks, is_xonly, msg)
psig = sign(secnonce, secshare, my_id, session_ctx)
return (pubnonce, psig)
def partial_sig_verify(psig: bytes, ids: List[int], pubnonces: List[bytes], pubshares: List[PlainPk], tweaks: List[bytes], is_xonly: List[bool], msg: bytes, i: int) -> bool:
if not len(ids) == len(pubnonces) == len(pubshares):
raise ValueError('The ids, pubnonces and pubshares arrays must have the same length.')
if len(tweaks) != len(is_xonly):
raise ValueError('The tweaks and is_xonly arrays must have the same length.')
aggnonce = nonce_agg(pubnonces, ids)
session_ctx = SessionContext(aggnonce, ids, pubshares, tweaks, is_xonly, msg)
return partial_sig_verify_internal(psig, ids[i], pubnonces[i], pubshares[i], session_ctx)
#todo: catch `cpoint`` ValueError and return false
def partial_sig_verify_internal(psig: bytes, my_id: int, pubnonce: bytes, pubshare: bytes, session_ctx: SessionContext) -> bool:
(Q, gacc, _, b, R, e) = get_session_values(session_ctx)
try:
s = Scalar.from_bytes_checked(psig)
except ValueError:
return False
if not session_has_signer_pubshare(session_ctx, pubshare):
return False
R_s1 = cpoint(pubnonce[0:33])
R_s2 = cpoint(pubnonce[33:66])
Re_s_ = R_s1 + b * R_s2
Re_s = Re_s_ if R.has_even_y() else -Re_s_
P = cpoint(pubshare)
if P is None:
return False
a = get_session_interpolating_value(session_ctx, my_id)
g = Scalar(1) if Q.has_even_y() else Scalar(-1)
g_ = g * gacc
return s * G == Re_s + (e * a * g_) * P
def partial_sig_agg(psigs: List[bytes], ids: List[int], session_ctx: SessionContext) -> bytes:
assert AGGREGATOR_ID not in ids
if len(psigs) != len(ids):
raise ValueError('The psigs and ids arrays must have the same length.')
(Q, _, tacc, _, R, e) = get_session_values(session_ctx)
s = Scalar(0)
for my_id, psig in zip(ids, psigs):
s_i = int_from_bytes(psig)
try:
s_i = Scalar.from_bytes_checked(psig)
except ValueError:
raise InvalidContributionError(my_id, "psig")
s = s + s_i
g = Scalar(1) if Q.has_even_y() else Scalar(-1)
s = s + e * g * tacc
return xbytes(R) + s.to_bytes()

10
src/jmfrost/secp256k1lab/CHANGELOG.md

@ -0,0 +1,10 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2025-03-31
Initial release.

23
src/jmfrost/secp256k1lab/COPYING

@ -0,0 +1,23 @@
The MIT License (MIT)
Copyright (c) 2009-2024 The Bitcoin Core developers
Copyright (c) 2009-2024 Bitcoin Developers
Copyright (c) 2025- The secp256k1lab Developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

13
src/jmfrost/secp256k1lab/README.md

@ -0,0 +1,13 @@
secp256k1lab
============
![Dependencies: None](https://img.shields.io/badge/dependencies-none-success)
An INSECURE implementation of the secp256k1 elliptic curve and related cryptographic schemes written in Python, intended for prototyping, experimentation and education.
Features:
* Low-level secp256k1 field and group arithmetic.
* Schnorr signing/verification and key generation according to [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki).
* ECDH key exchange.
WARNING: The code in this library is slow and trivially vulnerable to side channel attacks.

0
src/jmfrost/secp256k1lab/__init__.py

73
src/jmfrost/secp256k1lab/bip340.py

@ -0,0 +1,73 @@
# The following functions are based on the BIP 340 reference implementation:
# https://github.com/bitcoin/bips/blob/master/bip-0340/reference.py
from .secp256k1 import FE, GE, G
from .util import int_from_bytes, bytes_from_int, xor_bytes, tagged_hash
def pubkey_gen(seckey: bytes) -> bytes:
d0 = int_from_bytes(seckey)
if not (1 <= d0 <= GE.ORDER - 1):
raise ValueError("The secret key must be an integer in the range 1..n-1.")
P = d0 * G
assert not P.infinity
return P.to_bytes_xonly()
def schnorr_sign(
msg: bytes, seckey: bytes, aux_rand: bytes, tag_prefix: str = "BIP0340"
) -> bytes:
d0 = int_from_bytes(seckey)
if not (1 <= d0 <= GE.ORDER - 1):
raise ValueError("The secret key must be an integer in the range 1..n-1.")
if len(aux_rand) != 32:
raise ValueError("aux_rand must be 32 bytes instead of %i." % len(aux_rand))
P = d0 * G
assert not P.infinity
d = d0 if P.has_even_y() else GE.ORDER - d0
t = xor_bytes(bytes_from_int(d), tagged_hash(tag_prefix + "/aux", aux_rand))
k0 = (
int_from_bytes(tagged_hash(tag_prefix + "/nonce", t + P.to_bytes_xonly() + msg))
% GE.ORDER
)
if k0 == 0:
raise RuntimeError("Failure. This happens only with negligible probability.")
R = k0 * G
assert not R.infinity
k = k0 if R.has_even_y() else GE.ORDER - k0
e = (
int_from_bytes(
tagged_hash(
tag_prefix + "/challenge", R.to_bytes_xonly() + P.to_bytes_xonly() + msg
)
)
% GE.ORDER
)
sig = R.to_bytes_xonly() + bytes_from_int((k + e * d) % GE.ORDER)
assert schnorr_verify(msg, P.to_bytes_xonly(), sig, tag_prefix=tag_prefix)
return sig
def schnorr_verify(
msg: bytes, pubkey: bytes, sig: bytes, tag_prefix: str = "BIP0340"
) -> bool:
if len(pubkey) != 32:
raise ValueError("The public key must be a 32-byte array.")
if len(sig) != 64:
raise ValueError("The signature must be a 64-byte array.")
try:
P = GE.from_bytes_xonly(pubkey)
except ValueError:
return False
r = int_from_bytes(sig[0:32])
s = int_from_bytes(sig[32:64])
if (r >= FE.SIZE) or (s >= GE.ORDER):
return False
e = (
int_from_bytes(tagged_hash(tag_prefix + "/challenge", sig[0:32] + pubkey + msg))
% GE.ORDER
)
R = s * G - e * P
if R.infinity or (not R.has_even_y()) or (R.x != r):
return False
return True

16
src/jmfrost/secp256k1lab/ecdh.py

@ -0,0 +1,16 @@
import hashlib
from .secp256k1 import GE, Scalar
def ecdh_compressed_in_raw_out(seckey: bytes, pubkey: bytes) -> GE:
"""TODO"""
shared_secret = Scalar.from_bytes_checked(seckey) * GE.from_bytes_compressed(pubkey)
assert not shared_secret.infinity # prime-order group
return shared_secret
def ecdh_libsecp256k1(seckey: bytes, pubkey: bytes) -> bytes:
"""TODO"""
shared_secret = ecdh_compressed_in_raw_out(seckey, pubkey)
return hashlib.sha256(shared_secret.to_bytes_compressed()).digest()

15
src/jmfrost/secp256k1lab/keys.py

@ -0,0 +1,15 @@
from .secp256k1 import GE, G
from .util import int_from_bytes
# The following function is based on the BIP 327 reference implementation
# https://github.com/bitcoin/bips/blob/master/bip-0327/reference.py
# Return the plain public key corresponding to a given secret key
def pubkey_gen_plain(seckey: bytes) -> bytes:
d0 = int_from_bytes(seckey)
if not (1 <= d0 <= GE.ORDER - 1):
raise ValueError("The secret key must be an integer in the range 1..n-1.")
P = d0 * G
assert not P.infinity
return P.to_bytes_compressed()

454
src/jmfrost/secp256k1lab/secp256k1.py

@ -0,0 +1,454 @@
# Copyright (c) 2022-2023 The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Test-only implementation of low-level secp256k1 field and group arithmetic
It is designed for ease of understanding, not performance.
WARNING: This code is slow and trivially vulnerable to side channel attacks. Do not use for
anything but tests.
Exports:
* FE: class for secp256k1 field elements
* GE: class for secp256k1 group elements
* G: the secp256k1 generator point
"""
# TODO Docstrings of methods still say "field element"
class APrimeFE:
"""Objects of this class represent elements of a prime field.
They are represented internally in numerator / denominator form, in order to delay inversions.
"""
# The size of the field (also its modulus and characteristic).
SIZE: int
def __init__(self, a=0, b=1):
"""Initialize a field element a/b; both a and b can be ints or field elements."""
if isinstance(a, type(self)):
num = a._num
den = a._den
else:
num = a % self.SIZE
den = 1
if isinstance(b, type(self)):
den = (den * b._num) % self.SIZE
num = (num * b._den) % self.SIZE
else:
den = (den * b) % self.SIZE
assert den != 0
if num == 0:
den = 1
self._num = num
self._den = den
def __add__(self, a):
"""Compute the sum of two field elements (second may be int)."""
if isinstance(a, type(self)):
return type(self)(self._num * a._den + self._den * a._num, self._den * a._den)
if isinstance(a, int):
return type(self)(self._num + self._den * a, self._den)
return NotImplemented
def __radd__(self, a):
"""Compute the sum of an integer and a field element."""
return type(self)(a) + self
@classmethod
# REVIEW This should be
# def sum(cls, *es: Iterable[Self]) -> Self:
# but Self needs the typing_extension package on Python <= 3.12.
def sum(cls, *es):
"""Compute the sum of field elements.
sum(a, b, c, ...) is identical to (0 + a + b + c + ...)."""
return sum(es, start=cls(0))
def __sub__(self, a):
"""Compute the difference of two field elements (second may be int)."""
if isinstance(a, type(self)):
return type(self)(self._num * a._den - self._den * a._num, self._den * a._den)
if isinstance(a, int):
return type(self)(self._num - self._den * a, self._den)
return NotImplemented
def __rsub__(self, a):
"""Compute the difference of an integer and a field element."""
return type(self)(a) - self
def __mul__(self, a):
"""Compute the product of two field elements (second may be int)."""
if isinstance(a, type(self)):
return type(self)(self._num * a._num, self._den * a._den)
if isinstance(a, int):
return type(self)(self._num * a, self._den)
return NotImplemented
def __rmul__(self, a):
"""Compute the product of an integer with a field element."""
return type(self)(a) * self
def __truediv__(self, a):
"""Compute the ratio of two field elements (second may be int)."""
if isinstance(a, type(self)) or isinstance(a, int):
return type(self)(self, a)
return NotImplemented
def __pow__(self, a):
"""Raise a field element to an integer power."""
return type(self)(pow(self._num, a, self.SIZE), pow(self._den, a, self.SIZE))
def __neg__(self):
"""Negate a field element."""
return type(self)(-self._num, self._den)
def __int__(self):
"""Convert a field element to an integer in range 0..SIZE-1. The result is cached."""
if self._den != 1:
self._num = (self._num * pow(self._den, -1, self.SIZE)) % self.SIZE
self._den = 1
return self._num
def sqrt(self):
"""Compute the square root of a field element if it exists (None otherwise)."""
raise NotImplementedError
def is_square(self):
"""Determine if this field element has a square root."""
# A more efficient algorithm is possible here (Jacobi symbol).
return self.sqrt() is not None
def is_even(self):
"""Determine whether this field element, represented as integer in 0..SIZE-1, is even."""
return int(self) & 1 == 0
def __eq__(self, a):
"""Check whether two field elements are equal (second may be an int)."""
if isinstance(a, type(self)):
return (self._num * a._den - self._den * a._num) % self.SIZE == 0
return (self._num - self._den * a) % self.SIZE == 0
def to_bytes(self):
"""Convert a field element to a 32-byte array (BE byte order)."""
return int(self).to_bytes(32, 'big')
@classmethod
def from_int_checked(cls, v):
"""Convert an integer to a field element (no overflow allowed)."""
if v >= cls.SIZE:
raise ValueError
return cls(v)
@classmethod
def from_int_wrapping(cls, v):
"""Convert an integer to a field element (reduced modulo SIZE)."""
return cls(v % cls.SIZE)
@classmethod
def from_bytes_checked(cls, b):
"""Convert a 32-byte array to a field element (BE byte order, no overflow allowed)."""
v = int.from_bytes(b, 'big')
return cls.from_int_checked(v)
@classmethod
def from_bytes_wrapping(cls, b):
"""Convert a 32-byte array to a field element (BE byte order, reduced modulo SIZE)."""
v = int.from_bytes(b, 'big')
return cls.from_int_wrapping(v)
def __str__(self):
"""Convert this field element to a 64 character hex string."""
return f"{int(self):064x}"
def __repr__(self):
"""Get a string representation of this field element."""
return f"{type(self).__qualname__}(0x{int(self):x})"
class FE(APrimeFE):
SIZE = 2**256 - 2**32 - 977
def sqrt(self):
# Due to the fact that our modulus p is of the form (p % 4) == 3, the Tonelli-Shanks
# algorithm (https://en.wikipedia.org/wiki/Tonelli-Shanks_algorithm) is simply
# raising the argument to the power (p + 1) / 4.
# To see why: (p-1) % 2 = 0, so 2 divides the order of the multiplicative group,
# and thus only half of the non-zero field elements are squares. An element a is
# a (nonzero) square when Euler's criterion, a^((p-1)/2) = 1 (mod p), holds. We're
# looking for x such that x^2 = a (mod p). Given a^((p-1)/2) = 1, that is equivalent
# to x^2 = a^(1 + (p-1)/2) mod p. As (1 + (p-1)/2) is even, this is equivalent to
# x = a^((1 + (p-1)/2)/2) mod p, or x = a^((p+1)/4) mod p.
v = int(self)
s = pow(v, (self.SIZE + 1) // 4, self.SIZE)
if s**2 % self.SIZE == v:
return type(self)(s)
return None
class Scalar(APrimeFE):
"""TODO Docstring"""
SIZE = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
class GE:
"""Objects of this class represent secp256k1 group elements (curve points or infinity)
GE objects are immutable.
Normal points on the curve have fields:
* x: the x coordinate (a field element)
* y: the y coordinate (a field element, satisfying y^2 = x^3 + 7)
* infinity: False
The point at infinity has field:
* infinity: True
"""
# TODO The following two class attributes should probably be just getters as
# classmethods to enforce immutability. Unfortunately Python makes it hard
# to create "classproperties". `G` could then also be just a classmethod.
# Order of the group (number of points on the curve, plus 1 for infinity)
ORDER = Scalar.SIZE
# Number of valid distinct x coordinates on the curve.
ORDER_HALF = ORDER // 2
@property
def infinity(self):
"""Whether the group element is the point at infinity."""
return self._infinity
@property
def x(self):
"""The x coordinate (a field element) of a non-infinite group element."""
assert not self.infinity
return self._x
@property
def y(self):
"""The y coordinate (a field element) of a non-infinite group element."""
assert not self.infinity
return self._y
def __init__(self, x=None, y=None):
"""Initialize a group element with specified x and y coordinates, or infinity."""
if x is None:
# Initialize as infinity.
assert y is None
self._infinity = True
else:
# Initialize as point on the curve (and check that it is).
fx = FE(x)
fy = FE(y)
assert fy**2 == fx**3 + 7
self._infinity = False
self._x = fx
self._y = fy
def __add__(self, a):
"""Add two group elements together."""
# Deal with infinity: a + infinity == infinity + a == a.
if self.infinity:
return a
if a.infinity:
return self
if self.x == a.x:
if self.y != a.y:
# A point added to its own negation is infinity.
assert self.y + a.y == 0
return GE()
else:
# For identical inputs, use the tangent (doubling formula).
lam = (3 * self.x**2) / (2 * self.y)
else:
# For distinct inputs, use the line through both points (adding formula).
lam = (self.y - a.y) / (self.x - a.x)
# Determine point opposite to the intersection of that line with the curve.
x = lam**2 - (self.x + a.x)
y = lam * (self.x - x) - self.y
return GE(x, y)
@staticmethod
def sum(*ps):
"""Compute the sum of group elements.
GE.sum(a, b, c, ...) is identical to (GE() + a + b + c + ...)."""
return sum(ps, start=GE())
@staticmethod
def batch_mul(*aps):
"""Compute a (batch) scalar group element multiplication.
GE.batch_mul((a1, p1), (a2, p2), (a3, p3)) is identical to a1*p1 + a2*p2 + a3*p3,
but more efficient."""
# Reduce all the scalars modulo order first (so we can deal with negatives etc).
naps = [(int(a), p) for a, p in aps]
# Start with point at infinity.
r = GE()
# Iterate over all bit positions, from high to low.
for i in range(255, -1, -1):
# Double what we have so far.
r = r + r
# Add then add the points for which the corresponding scalar bit is set.
for (a, p) in naps:
if (a >> i) & 1:
r += p
return r
def __rmul__(self, a):
"""Multiply an integer with a group element."""
if self == G:
return FAST_G.mul(Scalar(a))
return GE.batch_mul((Scalar(a), self))
def __neg__(self):
"""Compute the negation of a group element."""
if self.infinity:
return self
return GE(self.x, -self.y)
def __sub__(self, a):
"""Subtract a group element from another."""
return self + (-a)
def __eq__(self, a):
"""Check if two group elements are equal."""
return (self - a).infinity
def has_even_y(self):
"""Determine whether a non-infinity group element has an even y coordinate."""
assert not self.infinity
return self.y.is_even()
def to_bytes_compressed(self):
"""Convert a non-infinite group element to 33-byte compressed encoding."""
assert not self.infinity
return bytes([3 - self.y.is_even()]) + self.x.to_bytes()
def to_bytes_compressed_with_infinity(self):
"""Convert a group element to 33-byte compressed encoding, mapping infinity to zeros."""
if self.infinity:
return 33 * b"\x00"
return self.to_bytes_compressed()
def to_bytes_uncompressed(self):
"""Convert a non-infinite group element to 65-byte uncompressed encoding."""
assert not self.infinity
return b'\x04' + self.x.to_bytes() + self.y.to_bytes()
def to_bytes_xonly(self):
"""Convert (the x coordinate of) a non-infinite group element to 32-byte xonly encoding."""
assert not self.infinity
return self.x.to_bytes()
@staticmethod
def lift_x(x):
"""Return group element with specified field element as x coordinate (and even y)."""
y = (FE(x)**3 + 7).sqrt()
if y is None:
raise ValueError
if not y.is_even():
y = -y
return GE(x, y)
@staticmethod
def from_bytes_compressed(b):
"""Convert a compressed to a group element."""
assert len(b) == 33
if b[0] != 2 and b[0] != 3:
raise ValueError
x = FE.from_bytes_checked(b[1:])
r = GE.lift_x(x)
if b[0] == 3:
r = -r
return r
@staticmethod
def from_bytes_uncompressed(b):
"""Convert an uncompressed to a group element."""
assert len(b) == 65
if b[0] != 4:
raise ValueError
x = FE.from_bytes_checked(b[1:33])
y = FE.from_bytes_checked(b[33:])
if y**2 != x**3 + 7:
raise ValueError
return GE(x, y)
@staticmethod
def from_bytes(b):
"""Convert a compressed or uncompressed encoding to a group element."""
assert len(b) in (33, 65)
if len(b) == 33:
return GE.from_bytes_compressed(b)
else:
return GE.from_bytes_uncompressed(b)
@staticmethod
def from_bytes_xonly(b):
"""Convert a point given in xonly encoding to a group element."""
assert len(b) == 32
x = FE.from_bytes_checked(b)
r = GE.lift_x(x)
return r
@staticmethod
def is_valid_x(x):
"""Determine whether the provided field element is a valid X coordinate."""
return (FE(x)**3 + 7).is_square()
def __str__(self):
"""Convert this group element to a string."""
if self.infinity:
return "(inf)"
return f"({self.x},{self.y})"
def __repr__(self):
"""Get a string representation for this group element."""
if self.infinity:
return "GE()"
return f"GE(0x{int(self.x):x},0x{int(self.y):x})"
def __hash__(self):
"""Compute a non-cryptographic hash of the group element."""
if self.infinity:
return 0 # 0 is not a valid x coordinate
return int(self.x)
# The secp256k1 generator point
G = GE.lift_x(0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798)
class FastGEMul:
"""Table for fast multiplication with a constant group element.
Speed up scalar multiplication with a fixed point P by using a precomputed lookup table with
its powers of 2:
table = [P, 2*P, 4*P, (2^3)*P, (2^4)*P, ..., (2^255)*P]
During multiplication, the points corresponding to each bit set in the scalar are added up,
i.e. on average ~128 point additions take place.
"""
def __init__(self, p):
self.table = [p] # table[i] = (2^i) * p
for _ in range(255):
p = p + p
self.table.append(p)
def mul(self, a):
result = GE()
a = int(a)
for bit in range(a.bit_length()):
if a & (1 << bit):
result += self.table[bit]
return result
# Precomputed table with multiples of G for fast multiplication
FAST_G = FastGEMul(G)

24
src/jmfrost/secp256k1lab/util.py

@ -0,0 +1,24 @@
import hashlib
# This implementation can be sped up by storing the midstate after hashing
# tag_hash instead of rehashing it all the time.
def tagged_hash(tag: str, msg: bytes) -> bytes:
tag_hash = hashlib.sha256(tag.encode()).digest()
return hashlib.sha256(tag_hash + tag_hash + msg).digest()
def bytes_from_int(x: int) -> bytes:
return x.to_bytes(32, byteorder="big")
def xor_bytes(b0: bytes, b1: bytes) -> bytes:
return bytes(x ^ y for (x, y) in zip(b0, b1))
def int_from_bytes(b: bytes) -> int:
return int.from_bytes(b, byteorder="big")
def hash_sha256(b: bytes) -> bytes:
return hashlib.sha256(b).digest()

2
src/jmqtui/_compile.py

@ -4,4 +4,4 @@ import os
# `gui-dev` dependencies must be installed prior to execution.
def compile_ui():
os.system('pyside2-uic jmqtui/open_wallet_dialog.ui -o jmqtui/open_wallet_dialog.py')
os.system('pyside6-uic jmqtui/open_wallet_dialog.ui -o jmqtui/open_wallet_dialog.py')

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save