Skip to content

Commit 4723903

Browse files
committed
Merge branch 'feature-ripple'
2 parents ecc5619 + 85c2523 commit 4723903

File tree

9 files changed

+295
-50
lines changed

9 files changed

+295
-50
lines changed

images/XRP.png

42.3 KB
Loading

images/XRP.svg

Lines changed: 80 additions & 0 deletions
Loading

slip39/api.py

Lines changed: 135 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -161,23 +161,110 @@ class BinanceMainnet( cryptocurrencies.Cryptocurrency ):
161161
WIF_SECRET_KEY = 0x80
162162

163163

164-
class Account( hdwallet.HDWallet ):
164+
class RippleMainnet( cryptocurrencies.Cryptocurrency ):
165+
"""The standard HDWallet.p2pkh_address (Pay to Public Key Hash) encoding is used, w/ a prefix of
166+
00. However, the XRP-specific base-58 encoding is used, resulting in a fixed 'r' prefix.
165167
166-
"""Supports producing Legacy addresses for Bitcoin, and Litecoin. Doge (D...) and Ethereum (0x...)
168+
See: https://xrpl.org/accounts.html#address-encoding.
169+
170+
"""
171+
NAME = "Ripple"
172+
SYMBOL = "XRP"
173+
NETWORK = "mainnet"
174+
SOURCE_CODE = "https://github.com/ripple/rippled"
175+
COIN_TYPE = cryptocurrencies.CoinType({
176+
"INDEX": 144,
177+
"HARDENED": True
178+
})
179+
180+
PUBLIC_KEY_ADDRESS = 0x00 # Results in the prefix r..., when used w/ the Ripple base-58 alphabet
181+
SEGWIT_ADDRESS = cryptocurrencies.SegwitAddress({
182+
"HRP": None,
183+
"VERSION": 0x00
184+
})
185+
186+
EXTENDED_PRIVATE_KEY = cryptocurrencies.ExtendedPrivateKey({
187+
"P2PKH": None,
188+
"P2SH": None,
189+
"P2WPKH": None,
190+
"P2WPKH_IN_P2SH": None,
191+
"P2WSH": None,
192+
"P2WSH_IN_P2SH": None,
193+
})
194+
EXTENDED_PUBLIC_KEY = cryptocurrencies.ExtendedPublicKey({
195+
"P2PKH": None,
196+
"P2SH": None,
197+
"P2WPKH": None,
198+
"P2WPKH_IN_P2SH": None,
199+
"P2WSH": None,
200+
"P2WSH_IN_P2SH": None,
201+
})
202+
203+
MESSAGE_PREFIX = None
204+
DEFAULT_PATH = f"m/44'/{str(COIN_TYPE)}/0'/0/0"
205+
WIF_SECRET_KEY = 0x80
206+
207+
208+
class XRPHDWallet( hdwallet.HDWallet ) :
209+
"""The XRP address format uses the standard p2pkh_address formulation, from
210+
https://xrpl.org/accounts.html#creating-accounts:
211+
212+
The ripemd160 hash of sha256 hash of public key, then base58-encoded w/ 4-byte checksum. The
213+
base-58 dictionary used is the standard Ripple (not Bitcoin!) alphabet:
214+
215+
rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz
216+
217+
NOTE: Only secp256k1 keypairs are supported; these are the default for the Ripple ledger.
218+
219+
"""
220+
def p2pkh_address( self ):
221+
p2pkh_btc = super( XRPHDWallet, self ).p2pkh_address()
222+
p2pkh = base58.b58decode_check( p2pkh_btc )
223+
return base58.b58encode_check( p2pkh, base58.RIPPLE_ALPHABET ).decode( 'UTF-8' )
224+
225+
226+
class Account:
227+
"""A Cryptocurrency "Account" / Wallet, based on a variety of underlying Python crypto-asset
228+
support modules. Presently, only meherett/python-hdwallet is used
229+
230+
An appropriate hdwallet-like wrapper is built, for any crypto-asset supported using another
231+
module. The required hdwallet API calls are:
232+
233+
.from_seed -- start deriving from the provided seed
234+
.from_mnemonic -- start deriving from the provided seed via BIP-39 mnemonic
235+
.clean_derivation -- forget any prior derivation path
236+
.from_path -- derive a wallet from the specified derivation path
237+
.p2pkh_address -- produce a Legacy format address
238+
.p2sh_address -- produce a SegWit format address
239+
.p2wpkh_address -- produce a Bech32 format address
240+
.path -- return the current wallet derivation path
241+
.private_key -- return the current wallet's private key
242+
243+
For testing eg. BIP-38 encrypted wallets:
244+
245+
.from_private_key -- import a specific private key
246+
.from_encrypted -- import an encrypted wallet
247+
248+
Also expect the following attributes to be available:
249+
250+
._cryptocurrency.SYMBOL: The short name of the crypto-asset, eg 'XRP'
251+
252+
Supports producing Legacy addresses for Bitcoin, and Litecoin. Doge (D...) and Ethereum (0x...)
167253
addresses use standard BIP44 derivation.
168254
169-
| Crypto | Semantic | Path | Address | Support |
170-
|--------+----------+------------------+---------+---------|
171-
| ETH | Legacy | m/44'/60'/0'/0/0 | 0x... | |
172-
| BNB | Legacy | m/44'/60'/0'/0/0 | 0x... | Beta |
173-
| CRO | Bech32 | m/44'/60'/0'/0/0 | crc1... | Beta |
174-
| BTC | Legacy | m/44'/ 0'/0'/0/0 | 1... | |
175-
| | SegWit | m/44'/ 0'/0'/0/0 | 3... | |
176-
| | Bech32 | m/84'/ 0'/0'/0/0 | bc1... | |
177-
| LTC | Legacy | m/44'/ 2'/0'/0/0 | L... | |
178-
| | SegWit | m/44'/ 2'/0'/0/0 | M... | |
179-
| | Bech32 | m/84'/ 2'/0'/0/0 | ltc1... | |
180-
| DOGE | Legacy | m/44'/ 3'/0'/0/0 | D... | |
255+
| Crypto | Semantic | Path | Address | Support |
256+
|--------+----------+-------------------+---------+---------|
257+
| ETH | Legacy | m/44'/ 60'/0'/0/0 | 0x... | |
258+
| BNB | Legacy | m/44'/ 60'/0'/0/0 | 0x... | Beta |
259+
| CRO | Bech32 | m/44'/ 60'/0'/0/0 | crc1... | Beta |
260+
| BTC | Legacy | m/44'/ 0'/0'/0/0 | 1... | |
261+
| | SegWit | m/44'/ 0'/0'/0/0 | 3... | |
262+
| | Bech32 | m/84'/ 0'/0'/0/0 | bc1... | |
263+
| LTC | Legacy | m/44'/ 2'/0'/0/0 | L... | |
264+
| | SegWit | m/44'/ 2'/0'/0/0 | M... | |
265+
| | Bech32 | m/84'/ 2'/0'/0/0 | ltc1... | |
266+
| DOGE | Legacy | m/44'/ 3'/0'/0/0 | D... | |
267+
| XRP | Legacy | m/44'/144'/0'/0/0 | r... | Beta |
181268
182269
"""
183270
CRYPTO_NAMES = dict( # Currently supported (in order of visibility)
@@ -187,9 +274,10 @@ class Account( hdwallet.HDWallet ):
187274
dogecoin = 'DOGE',
188275
cronos = 'CRO',
189276
binance = 'BNB',
277+
ripple = 'XRP',
190278
)
191279
CRYPTOCURRENCIES = set( CRYPTO_NAMES.values() )
192-
CRYPTOCURRENCIES_BETA = set( ('BNB', 'CRO') )
280+
CRYPTOCURRENCIES_BETA = set( ('BNB', 'CRO', 'XRP') )
193281

194282
ETHJS_ENCRYPT = set( ('ETH', 'CRO', 'BNB') ) # Can be encrypted w/ Ethereum JSON wallet
195283
BIP38_ENCRYPT = CRYPTOCURRENCIES - ETHJS_ENCRYPT # Can be encrypted w/ BIP-38
@@ -201,13 +289,18 @@ class Account( hdwallet.HDWallet ):
201289
DOGE = "legacy",
202290
CRO = "bech32",
203291
BNB = "legacy",
292+
XRP = "legacy",
204293
)
205294

206-
# Any locally-defined python-hdwallet cryptocurrencies, and any that may require some
207-
# adjustments when calling python-hdwallet address and other functions.
295+
# Any locally-defined python-hdwallet classes, cryptocurrency definitions, and any that may
296+
# require some adjustments when calling python-hdwallet address and other functions.
297+
CRYPTO_WALLET_CLS = dict(
298+
XRP = XRPHDWallet,
299+
)
208300
CRYPTO_LOCAL = dict(
209301
CRO = CronosMainnet,
210302
BNB = BinanceMainnet,
303+
XRP = RippleMainnet,
211304
)
212305
CRYPTO_LOCAL_SYMBOL = dict(
213306
BNB = "ETH"
@@ -237,7 +330,10 @@ class Account( hdwallet.HDWallet ):
237330
bech32 = "m/84'/2'/0'/0/0",
238331
),
239332
DOGE = dict(
240-
legacy ="m/44'/3'/0'/0/0",
333+
legacy = "m/44'/3'/0'/0/0",
334+
),
335+
XRP = dict(
336+
legacy = "m/44'/144'/0'/0/0",
241337
)
242338
)
243339

@@ -285,14 +381,16 @@ def supported( cls, crypto ):
285381

286382
def __init__( self, crypto, format=None ):
287383
crypto = Account.supported( crypto )
288-
cryptocurrency = self.CRYPTO_LOCAL.get( crypto, None ) # None, unless locally defined, above
384+
cryptocurrency = self.CRYPTO_LOCAL.get( crypto )
289385
self.format = format.lower() if format else Account.address_format( crypto )
290-
if self.format in ("legacy", "segwit",):
291-
self.hdwallet = hdwallet.BIP44HDWallet( symbol=crypto, cryptocurrency=cryptocurrency )
292-
elif self.format in ("bech32",):
293-
self.hdwallet = hdwallet.BIP84HDWallet( symbol=crypto, cryptocurrency=cryptocurrency )
294-
else:
386+
hdwallet_cls = self.CRYPTO_WALLET_CLS.get( crypto )
387+
if hdwallet_cls is None and self.format in ("legacy", "segwit",):
388+
hdwallet_cls = hdwallet.BIP44HDWallet
389+
if hdwallet_cls is None and self.format in ("bech32",):
390+
hdwallet_cls = hdwallet.BIP84HDWallet
391+
if hdwallet_cls is None:
295392
raise ValueError( f"{crypto} does not support address format {self.format}" )
393+
self.hdwallet = hdwallet_cls( symbol=crypto, cryptocurrency=cryptocurrency )
296394

297395
def from_seed( self, seed: str, path: str = None ) -> "Account":
298396
"""Derive the Account from the supplied seed and (optionally) path; uses the default derivation path
@@ -305,6 +403,15 @@ def from_seed( self, seed: str, path: str = None ) -> "Account":
305403
self.from_path( path )
306404
return self
307405

406+
def from_mnemonic( self, mnemonic: str, path: str = None ) -> "Account":
407+
"""Derive the Account from the supplied BIP-39 mnemonic and (optionally) path; uses the
408+
default derivation path for the Account address format, if None provided.
409+
410+
"""
411+
self.hdwallet.from_mnemonic( mnemonic )
412+
self.from_path( path )
413+
return self
414+
308415
def from_path( self, path: str = None ) -> "Account":
309416
"""Change the Account to derive from the provided path.
310417
@@ -376,6 +483,10 @@ def path( self ):
376483
def key( self ):
377484
return self.hdwallet.private_key()
378485

486+
@property
487+
def pubkey( self ):
488+
return self.hdwallet.public_key()
489+
379490
def from_private_key( self, private_key ):
380491
self.hdwallet.from_private_key( private_key )
381492
return self

slip39/api_test.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# -*- mode: python ; coding: utf-8 -*-
22
import json
33
import pytest
4+
import codecs
45

56
try:
67
import eth_account
@@ -18,7 +19,7 @@
1819
from . import account, create, addresses, addressgroups, accountgroups, Account
1920
from .recovery import recover
2021

21-
from .dependency_test import substitute, nonrandom_bytes, SEED_XMAS
22+
from .dependency_test import substitute, nonrandom_bytes, SEED_XMAS, SEED_ONES
2223

2324

2425
def test_account():
@@ -38,11 +39,20 @@ def test_account():
3839
acct = account( SEED_XMAS, crypto='Bitcoin', format='Legacy' )
3940
assert acct.address == '19FQ983heQEBXmopVNyJKf93XG7pN7sNFa'
4041
assert acct.path == "m/44'/0'/0'/0/0"
42+
assert acct.pubkey == '02d0bca9d976ad7303d1c0c3fd6ad0cb6bb78077d2ff158c16ac21bed763fb49a8'
4143

4244
acct = account( SEED_XMAS, crypto='Bitcoin', format='SegWit' )
4345
assert acct.address == '3HxUpD7E8Y31vDDgDq1VFdNXWViAgBjYJe'
4446
assert acct.path == "m/44'/0'/0'/0/0"
4547

48+
# And, confirm that we retrieve the same Bech32 address for the all-ones seed,
49+
# as on a real Trezor "Model T".
50+
acct = account( SEED_ONES, crypto='Bitcoin' )
51+
assert acct.address == 'bc1q9yscq3l2yfxlvnlk3cszpqefparrv7tk24u6pl'
52+
assert acct.path == "m/84'/0'/0'/0/0"
53+
assert acct.pubkey == '038f7fa5776f5359eb861994bee043f0b16a5ca24b66eb38696a7325d3e1717e72'
54+
55+
4656
acct = account( SEED_XMAS, crypto='Litecoin' )
4757
assert acct.address == 'ltc1qfjepkelqd3jx4e73s7p79lls6kqvvmak5pxy97'
4858
assert acct.path == "m/84'/2'/0'/0/0"
@@ -60,6 +70,31 @@ def test_account():
6070
assert acct.path == "m/44'/3'/0'/0/0"
6171

6272

73+
acct = account( SEED_ONES, crypto='Ripple' )
74+
assert acct.path == "m/44'/144'/0'/0/0" # Default
75+
assert acct.address == 'rsXwvDVHHPrSm23gogdxJdrJg9WBvqRE9m'
76+
77+
# Fake up some known Ripple pubkey --> addresses, by replacing the underlying "compressed"
78+
# public key function to return a fixed value. Test values from:
79+
# trezor-firmware/core/tests/test_apps.ripple.address.py
80+
compressed_save = acct.hdwallet.compressed
81+
acct.hdwallet.compressed = lambda: 'ed9434799226374926eda3b54b1b461b4abf7237962eae18528fea67595397fa32'
82+
assert acct.pubkey == 'ed9434799226374926eda3b54b1b461b4abf7237962eae18528fea67595397fa32'
83+
assert acct.address == 'rDTXLQ7ZKZVKz33zJbHjgVShjsBnqMBhmN'
84+
acct.hdwallet.compressed = lambda: '03e2b079e9b09ae8916da8f5ee40cbda9578dbe7c820553fe4d5f872eec7b1fdd4'
85+
assert acct.address == 'rhq549rEtUrJowuxQC2WsHNGLjAjBQdAe8'
86+
acct.hdwallet.compressed = lambda: '0282ee731039929e97db6aec242002e9aa62cd62b989136df231f4bb9b8b7c7eb2'
87+
assert acct.address == 'rKzE5DTyF9G6z7k7j27T2xEas2eMo85kmw'
88+
acct.hdwallet.compressed = compressed_save
89+
90+
# Test values from a Trezor "Model T" w/ root seed 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' loaded.
91+
# The Trezor Suite UI produced the following account derivation path and public address for:
92+
acct.from_mnemonic( 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' )
93+
assert acct.path == "m/44'/144'/0'/0/0" # Default
94+
assert acct.address == 'rUPzi4ZwoYxi7peKCqUkzqEuSrzSRyLguV' # From Trezor "Model T" w/
95+
assert acct.pubkey == '039d65db4964cbf2049ad49467a6b73e7fec7d6e6f8a303cfbdb99fa21c7a1d2bc'
96+
97+
6398
@pytest.mark.skipif( not scrypt or not eth_account,
6499
reason="pip install slip39[wallet] to support private key encryption" )
65100
def test_account_encrypt():
@@ -135,6 +170,20 @@ def test_account_encrypt():
135170
'something'
136171
).address == 'bc1qk0a9hr7wjfxeenz9nwenw9flhq0tmsf6vsgnn2'
137172

173+
# Ripple BIP-38 encrypted wallets. Should round-trip via BIP-38
174+
acct_xrp = Account( crypto='XRP' )
175+
acct_xrp.from_mnemonic( 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' )
176+
assert acct_xrp.path == "m/44'/144'/0'/0/0" # Default
177+
assert acct_xrp.address == 'rUPzi4ZwoYxi7peKCqUkzqEuSrzSRyLguV' # From Trezor "Model T" w/
178+
assert acct_xrp.pubkey == '039d65db4964cbf2049ad49467a6b73e7fec7d6e6f8a303cfbdb99fa21c7a1d2bc'
179+
acct_xrp_encrypted = acct_xrp.encrypted( 'password' )
180+
assert acct_xrp_encrypted == '6PYTRxHt4sPM9i6zagBJ4pWdaefJ1FfVQwFCWQxDhVBw7fJYpYP3kMPfro'
181+
182+
acct_dec = Account( crypto='XRP' ).from_encrypted( acct_xrp_encrypted, 'password' )
183+
acct_dec.path == "m/44'/144'/0'/0/0"
184+
assert acct_dec.address == 'rUPzi4ZwoYxi7peKCqUkzqEuSrzSRyLguV'
185+
assert acct_dec.pubkey == '039d65db4964cbf2049ad49467a6b73e7fec7d6e6f8a303cfbdb99fa21c7a1d2bc'
186+
138187

139188
@substitute( shamir_mnemonic.shamir, 'RANDOM_BYTES', nonrandom_bytes )
140189
def test_create():

0 commit comments

Comments
 (0)