Skip to content

Commit f8fad4b

Browse files
poltopoltostepansnigirev
authored
Add feature to export multiple accounts XPub keys (#258)
* Add feature to export multiple accounts XPub keys * Apply suggestions from code review still need to do a few modifs and test them. Co-authored-by: Stepan Snigirev <[email protected]> * refactor pr to avoid code duplication --------- Co-authored-by: polto <[email protected]> Co-authored-by: Stepan Snigirev <[email protected]>
1 parent 9444d31 commit f8fad4b

File tree

3 files changed

+175
-53
lines changed

3 files changed

+175
-53
lines changed

src/apps/xpubs/xpubs.py

Lines changed: 139 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55
from binascii import hexlify
66
from embit.liquid.networks import NETWORKS
77
from embit import bip32
8-
from helpers import is_liquid
8+
from helpers import is_liquid, SDCardFile
99
from io import BytesIO
1010
import platform
11-
import lvgl as lv
1211
from collections import OrderedDict
1312

1413
class XpubApp(BaseApp):
@@ -41,7 +40,8 @@ async def menu(self, show_screen, show_all=False):
4140
(0, "Show more keys"),
4241
(2, "Change account number"),
4342
(1, "Enter custom derivation"),
44-
(3, "Export all keys to SD"),
43+
(3, "Export all keys for this account"),
44+
(4, "Export multiple accounts"),
4545
]
4646
else:
4747
buttons = [
@@ -69,9 +69,14 @@ async def menu(self, show_screen, show_all=False):
6969
),
7070
]
7171
# wait for menu selection
72-
menuitem = await show_screen(Menu(buttons, last=(255, None),
73-
title="Select the key",
74-
note="Current account number: %d" % self.account))
72+
menuitem = await show_screen(
73+
Menu(
74+
buttons,
75+
title="Select the key",
76+
note="Current account number: %d" % self.account,
77+
last=(255, None),
78+
)
79+
)
7580

7681
# process the menu button:
7782
# back button
@@ -84,6 +89,15 @@ async def menu(self, show_screen, show_all=False):
8489
if der is not None:
8590
await self.show_xpub(der, show_screen)
8691
return True
92+
elif menuitem == 2:
93+
account = await show_screen(NumericScreen(current_val=str(self.account)))
94+
if account and int(account) >= 0x80000000:
95+
raise AppError("Account number too large")
96+
try:
97+
self.account = int(account)
98+
except:
99+
self.account = 0
100+
return await self.menu(show_screen)
87101
elif menuitem == 3:
88102
file_format = await self.save_menu(show_screen)
89103
if file_format:
@@ -93,66 +107,143 @@ async def menu(self, show_screen, show_all=False):
93107
"Public keys are saved to the file:\n\n%s" % filename,
94108
button_text="Close")
95109
)
96-
elif menuitem == 2:
97-
account = await show_screen(NumericScreen(current_val=str(self.account)))
98-
if account and int(account) > 0x80000000:
99-
raise AppError('Account number too large')
100-
try:
101-
self.account = int(account)
102-
except:
103-
self.account = 0
104-
return await self.menu(show_screen)
110+
elif menuitem == 4:
111+
from_account = await show_screen(
112+
NumericScreen(
113+
title="Enter START account number",
114+
current_val=str(self.account)
115+
)
116+
)
117+
to_account = await show_screen(
118+
NumericScreen(
119+
title="Enter END account number",
120+
current_val=str(self.account)
121+
)
122+
)
123+
if from_account is None or to_account is None:
124+
return
125+
if from_account == "":
126+
from_account = self.account
127+
if to_account == "":
128+
to_account = self.account
129+
from_account = int(from_account)
130+
to_account = int(to_account)
131+
file_format = await self.save_menu(show_screen)
132+
await self.export_multiple_accounts_xpubs(
133+
from_account, to_account, file_format, show_screen
134+
)
105135
else:
106136
await self.show_xpub(menuitem, show_screen)
107137
return True
108138
return False
109139

110-
def save_all_to_sd(self, file_format):
140+
def save_all_to_sd(self, file_format, account=None):
141+
if account is None:
142+
account = self.account
143+
111144
fingerprint = hexlify(self.keystore.fingerprint).decode()
112-
extension = "json"
113-
coin = NETWORKS[self.network]["bip32"]
114145

146+
extension = "txt" if file_format == self.export_specter_diy else "json"
147+
filename = "%s-%s-%d-all.%s" % (
148+
file_format, fingerprint, account, extension,
149+
)
150+
151+
if not platform.is_sd_present():
152+
raise AppError("Please insert SD card")
153+
154+
with SDCardFile(filename, "w") as f:
155+
self._dump_account(f, file_format, account)
156+
157+
return filename
158+
159+
async def export_multiple_accounts_xpubs(
160+
self,
161+
from_account,
162+
to_account,
163+
file_format,
164+
show_screen,
165+
):
166+
if from_account > to_account:
167+
from_account, to_account = to_account, from_account
168+
if to_account >= 0x80000000:
169+
raise AppError('Account number too large')
170+
fingerprint = hexlify(self.keystore.fingerprint).decode()
171+
if file_format == self.export_specter_diy:
172+
# in our format we can dump any number of accounts in one file
173+
filename = "%s-%s-%d-%d.txt" % (
174+
file_format, fingerprint, from_account, to_account
175+
)
176+
with SDCardFile(filename, "w") as f:
177+
for account in range(from_account, to_account+1):
178+
self.show_loader(title="Exporting account %d..." % account)
179+
self._dump_account(f, file_format, account)
180+
await show_screen(
181+
Alert(
182+
"Success!",
183+
"File was successfully saved under:\n\n%s" % filename,
184+
button_text="OK",
185+
)
186+
)
187+
else: # cc format - one file per account
188+
for account in range(from_account, to_account+1):
189+
self.show_loader(title="Exporting account %d..." % account)
190+
self.save_all_to_sd(file_format, account)
191+
await show_screen(
192+
Alert(
193+
"Success!",
194+
"All accounts are saved to corresponding files.",
195+
button_text="OK",
196+
)
197+
)
198+
199+
def _dump_account(self, f, file_format, account):
200+
"""dump all keys of one account to a file"""
201+
coin = NETWORKS[self.network]["bip32"]
115202
derivations = [
116-
('bip84', "p2wpkh", "m/84'/%d'/%d'" % (coin, self.account)),
117-
('bip86', "p2tr", "m/86'/%d'/%d'" % (coin, self.account)),
118-
('bip49', "p2sh-p2wpkh", "m/49'/%d'/%d'" % (coin, self.account)),
119-
('bip44', "p2pkh", "m/44'/%d'/%d'" % (coin, self.account)),
120-
('bip48_1', "p2sh-p2wsh", "m/48'/%d'/%d'/1'" % (coin, self.account)),
121-
('bip48_2', "p2wsh", "m/48'/%d'/%d'/2'" % (coin, self.account)),
203+
('bip84', "p2wpkh", "m/84'/%d'/%d'" % (coin, account)),
204+
('bip86', "p2tr", "m/86'/%d'/%d'" % (coin, account)),
205+
('bip49', "p2sh-p2wpkh", "m/49'/%d'/%d'" % (coin, account)),
206+
('bip44', "p2pkh", "m/44'/%d'/%d'" % (coin, account)),
207+
('bip48_1', "p2sh-p2wsh", "m/48'/%d'/%d'/1'" % (coin, account)),
208+
('bip48_2', "p2wsh", "m/48'/%d'/%d'/2'" % (coin, account)),
122209
]
210+
fingerprint = hexlify(self.keystore.fingerprint).decode()
123211

124212
if file_format == self.export_specter_diy:
125-
# text file with [fgp/der]xpub lines
126-
filedata = ""
127-
for der in derivations:
128-
xpub = self.keystore.get_xpub(der[2])
129-
filedata += "[%s/%s]%s\n" % (fingerprint, der[2][2:].replace("'","h"), xpub.to_base58(NETWORKS[self.network]["xpub"]))
130-
extension = "txt"
213+
for keytype, scripttype, der in derivations:
214+
xpub = self.keystore.get_xpub(der)
215+
f.write("[%s/%s]%s\n" % (
216+
fingerprint,
217+
der.replace("m/", "").replace("'","h"),
218+
xpub.to_base58(NETWORKS[self.network]["xpub"]),
219+
))
131220
else:
132221
# coldcard generic json format
133222
m = self.keystore.get_xpub("m")
134223
data = {
135224
"xpub": m.to_base58(NETWORKS[self.network]["xpub"]),
136225
"xfp": fingerprint,
137-
"account": self.account,
226+
"account": account,
138227
"chain": "BTC" if self.network == "main" else "XTN"
139228
}
140229

141-
for der in derivations:
142-
xpub = self.keystore.get_xpub(der[2])
230+
for keytype, scripttype, der in derivations:
231+
xpub = self.keystore.get_xpub(der)
143232

144-
data[der[0]] = {
145-
"name": der[1],
146-
"deriv": der[2],
233+
data[keytype] = {
234+
"name": scripttype,
235+
"deriv": der,
147236
"xpub": xpub.to_base58(NETWORKS[self.network]["xpub"]),
148-
"_pub": xpub.to_base58(bip32.detect_version(der[2], default="xpub", network=NETWORKS[self.network]))
237+
"_pub": xpub.to_base58(
238+
bip32.detect_version(
239+
der,
240+
default="xpub",
241+
network=NETWORKS[self.network]
242+
)
243+
)
149244
}
150245

151-
filedata = json.dumps(data).encode()
152-
153-
filename = "%s-%s-%d-all.%s" % (file_format, fingerprint, self.account, extension)
154-
self.write_file(filename, filedata)
155-
return filename
246+
json.dump(data, f)
156247

157248
async def process_host_command(self, stream, show_screen):
158249
if self.keystore.is_locked:
@@ -192,12 +283,15 @@ async def show_xpub(self, derivation, show_screen):
192283
fingerprint,
193284
derivation[1:],
194285
)
195-
res = await show_screen(XPubScreen(xpub=canonical, slip132=slip132, prefix=prefix))
286+
res = await show_screen(
287+
XPubScreen(xpub=canonical, slip132=slip132, prefix=prefix)
288+
)
196289
if res == XPubScreen.CREATE_WALLET:
197290
await self.create_wallet(derivation, canonical, prefix, ver, show_screen)
198291
elif res:
199292
filename = "%s-%s.txt" % (fingerprint, derivation[2:].replace("/", "-"))
200-
self.write_file(filename, res)
293+
with SDCardFile(filename, "w") as f:
294+
f.write(res)
201295
await show_screen(
202296
Alert("Saved!",
203297
"Extended public key is saved to the file:\n\n%s" % filename,
@@ -283,14 +377,6 @@ async def create_wallet(self, derivation, xpub, prefix, version, show_screen):
283377
await self.communicate(stream, app="wallets")
284378

285379

286-
def write_file(self, filename, filedata):
287-
if not platform.is_sd_present():
288-
raise AppError("SD card is not present")
289-
platform.mount_sdcard()
290-
with open(platform.fpath("/sd/%s" % filename), "w") as f:
291-
f.write(filedata)
292-
platform.unmount_sdcard()
293-
294380
async def save_menu(self, show_screen):
295381
buttons = [(0, "Specter-DIY (plaintext)"), (1, "Cold Card (json)")]
296382
# wait for menu selection
@@ -307,6 +393,7 @@ async def save_menu(self, show_screen):
307393
return self.export_coldcard
308394
return None
309395

396+
310397
def wipe(self):
311398
# nothing to delete
312399
pass

src/helpers.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,38 @@ def read_write(fin, fout, chunk_size=32):
174174
chunk = fin.read(chunk_size)
175175
total += fout.write(chunk)
176176
return total
177+
178+
class SDCardFile:
179+
"""open file on SD card, mount card on enter, unmount on exit
180+
181+
Usage:
182+
183+
with SDCardFile("/sd/blah", "w") as f:
184+
f.write("blah")
185+
"""
186+
def __init__(self, filename, *args, **kwargs):
187+
self._filename = filename
188+
self._args = args
189+
self._kwargs = kwargs
190+
self.file = None
191+
192+
def mount(self):
193+
platform.mount_sdcard()
194+
195+
def unmount(self):
196+
platform.unmount_sdcard()
197+
198+
def __enter__(self):
199+
self.mount()
200+
self.file = open(
201+
platform.fpath("/sd/" + self._filename),
202+
*self._args, **self._kwargs
203+
)
204+
return self.file
205+
206+
def __exit__(self, exc_type, exc_value, traceback):
207+
if self.file:
208+
self.file.close()
209+
self.file = None
210+
self.unmount()
211+

src/platform.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,4 +288,4 @@ def get_battery_status():
288288
return level, charging
289289
except Exception as e:
290290
print(e)
291-
return None, None
291+
return None, None

0 commit comments

Comments
 (0)