Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RPC-API: add ability to recover wallet #1461

Merged
merged 1 commit into from
Apr 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/api/wallet-rpc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,26 @@ paths:
schema:
$ref: '#/components/schemas/CreateWalletRequest'
description: wallet creation parameters
/wallet/recover:
post:
summary: recover a wallet from a seedphrase
operationId: recoverwallet
description: Give a filename (.jmdat must be included), a wallettype, a seedphrase and a password, create the wallet for the newly persisted wallet file. The wallettype variable must be one of "sw" - segwit native, "sw-legacy" - segwit legacy or "sw-fb" - segwit native with fidelity bonds supported, the last of which is the default. The seedphrase must be a single string with words space-separated, and must conform to BIP39 (else 400 is returned). Note that this operation cannot be performed when a wallet is already loaded (unlocked).
responses:
'201':
$ref: '#/components/responses/Create-201-OK'
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'409':
$ref: '#/components/responses/409-AlreadyExists'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RecoverWalletRequest'
description: wallet recovery parameters
/wallet/{walletname}/unlock:
post:
summary: decrypt an existing wallet
Expand Down Expand Up @@ -171,6 +191,33 @@ paths:
$ref: '#/components/responses/401-Unauthorized'
'404':
$ref: '#/components/responses/404-NotFound'
/wallet/{walletname}/rescanblockchain/{blockheight}:
get:
summary: Rescan the blockchain from a given blockheight
operationId: rescanblockchain
description: Use this operation on recovered wallets to re-sync the wallet
parameters:
- name: walletname
in: path
description: name of wallet including .jmdat
required: true
schema:
type: string
- name: blockheight
in: path
description: starting block height for the rescan
required: true
schema:
type: integer
responses:
'200':
$ref: "#/components/responses/RescanBlockchain-200-OK"
'400':
$ref: '#/components/responses/400-BadRequest'
'401':
$ref: '#/components/responses/401-Unauthorized'
'404':
$ref: '#/components/responses/404-NotFound'
/wallet/{walletname}/address/timelock/new/{lockdate}:
get:
security:
Expand Down Expand Up @@ -714,6 +761,7 @@ components:
- maker_running
- coinjoin_in_process
- wallet_name
- rescanning
properties:
session:
type: boolean
Expand Down Expand Up @@ -751,6 +799,8 @@ components:
type: string
nickname:
type: string
rescanning:
type: boolean
ListUtxosResponse:
type: object
properties:
Expand Down Expand Up @@ -960,6 +1010,27 @@ components:
wallettype:
type: string
example: "sw-fb"
RecoverWalletRequest:
type: object
required:
- walletname
- password
- wallettype
- seedphrase
properties:
walletname:
type: string
example: wallet.jmdat
password:
type: string
format: password
example: hunter2
wallettype:
type: string
example: "sw-fb"
seedphrase:
type: string
example: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
UnlockWalletRequest:
type: object
required:
Expand Down Expand Up @@ -1035,12 +1106,24 @@ components:
application/json:
schema:
$ref: "#/components/schemas/SessionResponse"
RescanBlockchain-200-OK:
description: "Blockchain rescan started successfully"
content:
application/json:
schema:
$ref: "#/components/schemas/SessionResponse"
Create-201-OK:
description: "wallet created successfully"
content:
application/json:
schema:
$ref: "#/components/schemas/CreateWalletResponse"
Recover-201-OK:
description: "wallet recovered successfully"
content:
application/json:
schema:
$ref: "#/components/schemas/CreateWalletResponse"
Unlock-200-OK:
description: "wallet unlocked successfully"
content:
Expand Down
34 changes: 34 additions & 0 deletions jmclient/jmclient/blockchaininterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import random
import sys
import time
from typing import Optional
from decimal import Decimal
import binascii
from twisted.internet import reactor, task
Expand Down Expand Up @@ -215,6 +216,39 @@ def get_block(self, blockheight):
return False
return block

def rescanblockchain(self, start_height: int, end_height: Optional[int] = None) -> None:
# Threading is not used in Joinmarket but due to blocking
# nature of this very slow RPC call, we need to fire and forget.
from threading import Thread
Thread(target=self.rescan_in_thread, args=(start_height,),
daemon=True).start()

def rescan_in_thread(self, start_height: int) -> None:
""" In order to not conflict with the existing main
JsonRPC connection in the main thread, this rescanning
thread creates a distinct JsonRPC object, just to make
this one RPC call `rescanblockchain <height>`, using the
same credentials.
"""
from jmclient.jsonrpc import JsonRpc
authstr = self.jsonRpc.authstr
user, password = authstr.split(":")
newjsonRpc = JsonRpc(self.jsonRpc.host,
self.jsonRpc.port,
user, password,
url=self.jsonRpc.url)
try:
newjsonRpc.call('rescanblockchain', [start_height])
except JsonRpcConnectionError:
log.error("Failure of RPC connection to Bitcoin Core. "
"Rescanning process not started.")

def getwalletinfo(self) -> dict:
""" Returns detailed about currently loaded (see `loadwallet`
call in __init__) Bitcoin Core wallet.
"""
return self._rpc("getwalletinfo", [])

def _rpc(self, method, args):
""" Returns the result of an rpc call to the Bitcoin Core RPC API.
If the connection is permanently or unrecognizably broken, None
Expand Down
116 changes: 96 additions & 20 deletions jmclient/jmclient/wallet_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
NotEnoughFundsException, get_tumble_log, get_tumble_schedule, \
get_schedule, get_tumbler_parser, schedule_to_text, \
tumbler_filter_orders_callback, tumbler_taker_finished_update, \
validate_address, FidelityBondMixin, \
ScheduleGenerationErrorNoFunds
validate_address, FidelityBondMixin, BaseWallet, WalletError, \
ScheduleGenerationErrorNoFunds, BIP39WalletMixin
from jmbase.support import get_log, utxostr_to_utxo

jlog = get_log()
Expand Down Expand Up @@ -108,6 +108,7 @@ class NotEnoughCoinsForTumbler(Exception):
class YieldGeneratorDataUnreadable(Exception):
pass


def get_ssl_context(cert_directory):
"""Construct an SSL context factory from the user's privatekey/cert.
TODO:
Expand Down Expand Up @@ -476,6 +477,8 @@ def dummy_restart_callback(msg):

# First, prepare authentication for the calling client:
self.set_token(wallet_name)
# return type is different for a newly created OR recovered
# wallet, in this case we use the 'seedphrase' kwarg as trigger:
if('seedphrase' in kwargs):
return make_jmwalletd_response(request,
status=201,
Expand Down Expand Up @@ -551,6 +554,23 @@ def check_daemon_ready(self):
raise BackendNotReady()
return (daemon_serving_host, daemon_serving_port)

def get_wallet_cls_from_type(self, wallettype: str) -> BaseWallet:
if wallettype == "sw":
return SegwitWallet
elif wallettype == "sw-legacy":
return SegwitLegacyWallet
elif wallettype == "sw-fb":
return SegwitWalletFidelityBonds
else:
raise InvalidRequestFormat()

def get_wallet_name_from_req(self, walletname: str) -> str:
""" use the config's data location combined with the json
data from the request to construct the wallet path
"""
wallet_root_path = os.path.join(jm_single().datadir, "wallets")
return os.path.join(wallet_root_path, walletname)

""" RPC begins here.
"""

Expand All @@ -576,6 +596,25 @@ def displaywallet(self, request, walletname):
walletinfo = 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'])
def rescanblockchain(self, request, walletname, blockheight):
""" This route lets the user trigger the rescan action in the backend.
Note that it technically "shouldn't" require a wallet to be loaded,
but since we hide all blockchain access behind the wallet service,
it currently *does* require this.
"""
print_req(request)
self.check_cookie(request)
if not self.services["wallet"]:
jlog.warn("rescanblockchain called, but no wallet service active.")
raise NoWalletFound()
if not self.wallet_name == walletname:
jlog.warn("called rescanblockchain with wrong wallet")
raise InvalidRequestFormat()
else:
self.services["wallet"].rescanblockchain(blockheight)
return make_jmwalletd_response(request, walletname=walletname)

@app.route('/session', methods=['GET'])
def session(self, request):
""" This route functions as a heartbeat, and communicates
Expand All @@ -596,9 +635,17 @@ def session(self, request):
schedule = None
offer_list = None
nickname = None
# We don't technically *know* the backend is not
# rescanning, but that would be a strange scenario:
rescanning = False

if self.services["wallet"]:
if self.services["wallet"].isRunning():
winfo = self.services["wallet"].get_backend_walletinfo()
if "scanning" in winfo and winfo["scanning"]:
# Note that if not 'false', it contains info
# that looks like: {'duration': 1, 'progress': Decimal('0.04665404082350701')}
rescanning = True
wallet_name = self.wallet_name
# At this point if an `auth_header` is present, it has been checked
# by the call to `check_cookie_if_present` above.
Expand Down Expand Up @@ -626,7 +673,8 @@ def session(self, request):
schedule=schedule,
wallet_name=wallet_name,
offer_list=offer_list,
nickname=nickname)
nickname=nickname,
rescanning=rescanning)

@app.route('/wallet/<string:walletname>/taker/direct-send', methods=['POST'])
def directsend(self, request, walletname):
Expand Down Expand Up @@ -829,24 +877,13 @@ def createwallet(self, request):
["walletname", "password", "wallettype"])
if not request_data:
raise InvalidRequestFormat()
wallettype = request_data["wallettype"]
if wallettype == "sw":
wallet_cls = SegwitWallet
elif wallettype == "sw-legacy":
wallet_cls = SegwitLegacyWallet
elif wallettype == "sw-fb":
wallet_cls = SegwitWalletFidelityBonds
else:
raise InvalidRequestFormat()
# use the config's data location combined with the json
# data to construct the wallet path:
wallet_root_path = os.path.join(jm_single().datadir, "wallets")
wallet_name = os.path.join(wallet_root_path,
request_data["walletname"])
wallet_cls = self.get_wallet_cls_from_type(
request_data["wallettype"])
try:
wallet = create_wallet(wallet_name,
request_data["password"].encode("ascii"),
4, wallet_cls=wallet_cls)
wallet = create_wallet(self.get_wallet_name_from_req(
request_data["walletname"]),
request_data["password"].encode("ascii"),
4, wallet_cls=wallet_cls)
# extension not yet supported in RPC create; TODO
seed, extension = wallet.get_mnemonic_words()
except RetryableStorageError:
Expand All @@ -859,6 +896,45 @@ def createwallet(self, request):
request_data["walletname"],
seedphrase=seed)

@app.route('/wallet/recover', methods=["POST"])
def recoverwallet(self, request):
print_req(request)
# we only handle one wallet at a time;
# if there is a currently unlocked wallet,
# refuse to process the request:
if self.services["wallet"]:
raise WalletAlreadyUnlocked()
request_data = self.get_POST_body(request,
["walletname", "password",
"wallettype", "seedphrase"])
if not request_data:
raise InvalidRequestFormat()
wallet_cls = self.get_wallet_cls_from_type(
request_data["wallettype"])
seedphrase = request_data["seedphrase"]
seedphrase = seedphrase.strip()
if not seedphrase:
raise InvalidRequestFormat()
try:
entropy = BIP39WalletMixin.entropy_from_mnemonic(seedphrase)
except WalletError:
# should only occur if the seedphrase is not valid BIP39:
raise InvalidRequestFormat()
try:
wallet = create_wallet(self.get_wallet_name_from_req(
request_data["walletname"]),
request_data["password"].encode("ascii"),
4, wallet_cls=wallet_cls, entropy=entropy)
except RetryableStorageError:
raise LockExists()
except StorageError:
raise WalletAlreadyExists()
# finally, after the wallet is successfully created, we should
# start the wallet service, then return info to the caller:
return self.initialize_wallet_service(request, wallet,
request_data["walletname"],
seedphrase=seedphrase)

@app.route('/wallet/<string:walletname>/unlock', methods=['POST'])
def unlockwallet(self, request, walletname):
""" If a user succeeds in authenticating and opening a
Expand Down
10 changes: 10 additions & 0 deletions jmclient/jmclient/wallet_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import itertools
import time
import sys
from typing import Optional
from decimal import Decimal
from copy import deepcopy
from twisted.internet import reactor
Expand Down Expand Up @@ -729,6 +730,15 @@ def get_transaction(self, txid):
def get_block_height(self, blockhash):
return self.bci.get_block_height(blockhash)

def rescanblockchain(self, start_height: int, end_height: Optional[int] = None) -> None:
self.bci.rescanblockchain(start_height, end_height)

def get_backend_walletinfo(self) -> dict:
""" 'Backend' wallet means the Bitcoin Core wallet,
which will always be loaded if self.bci is init-ed.
"""
return self.bci.getwalletinfo()

def get_transaction_block_height(self, tx):
""" Given a CTransaction object tx, return
the block height at which it was mined, or False
Expand Down
Loading