electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit 757467782a7c36332754d433d846b400b4aa770e
parent 9bd633fb0bd96b098596ccb55bea7a6fabf4465b
Author: ThomasV <thomasv@electrum.org>
Date:   Thu, 30 Jan 2020 18:09:32 +0100

Use attr.s instead of namedtuples for channel config

Diffstat:
Mcontrib/requirements/requirements.txt | 1+
Melectrum/lnchannel.py | 77++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Melectrum/lnpeer.py | 19+++++++------------
Melectrum/lnsweep.py | 2+-
Melectrum/lnutil.py | 115++++++++++++++++++++++++++++++++++++++-----------------------------------------
Melectrum/tests/test_lnchannel.py | 25+++++++++++--------------
Melectrum/tests/test_lnutil.py | 6+++---
7 files changed, 123 insertions(+), 122 deletions(-)

diff --git a/contrib/requirements/requirements.txt b/contrib/requirements/requirements.txt @@ -12,3 +12,4 @@ bitstring pycryptodomex>=3.7 jsonrpcserver jsonrpcclient +attrs diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py @@ -29,6 +29,7 @@ import json from enum import IntEnum from typing import Optional, Dict, List, Tuple, NamedTuple, Set, Callable, Iterable, Sequence, TYPE_CHECKING import time +import threading from . import ecc from .util import bfh, bh2u @@ -99,6 +100,8 @@ class ChannelJsonEncoder(json.JSONEncoder): return o.serialize() if isinstance(o, set): return list(o) + if hasattr(o, 'to_json') and callable(o.to_json): + return o.to_json() return super().default(o) RevokeAndAck = namedtuple("RevokeAndAck", ["per_commitment_secret", "next_per_commitment_point"]) @@ -110,7 +113,7 @@ class RemoteCtnTooFarInFuture(Exception): pass def decodeAll(d, local): for k, v in d.items(): if k == 'revocation_store': - yield (k, RevocationStore.from_json_obj(v)) + yield (k, RevocationStore(v)) elif k.endswith("_basepoint") or k.endswith("_key"): if local: yield (k, Keypair(**dict(decodeAll(v, local)))) @@ -152,6 +155,7 @@ class Channel(Logger): self.lnworker = lnworker # type: Optional[LNWallet] self.sweep_address = sweep_address assert 'local_state' not in state + self.db_lock = self.lnworker.wallet.storage.db.lock if self.lnworker else threading.RLock() self.config = {} self.config[LOCAL] = state["local_config"] if type(self.config[LOCAL]) is not LocalConfig: @@ -181,6 +185,7 @@ class Channel(Logger): self.peer_state = peer_states.DISCONNECTED self.sweep_info = {} # type: Dict[str, Dict[str, SweepInfo]] self._outgoing_channel_update = None # type: Optional[bytes] + self.revocation_store = RevocationStore(state["revocation_store"]) def get_feerate(self, subject, ctn): return self.hm.get_feerate(subject, ctn) @@ -211,12 +216,13 @@ class Channel(Logger): return out def open_with_first_pcp(self, remote_pcp, remote_sig): - self.config[REMOTE] = self.config[REMOTE]._replace(current_per_commitment_point=remote_pcp, - next_per_commitment_point=None) - self.config[LOCAL] = self.config[LOCAL]._replace(current_commitment_signature=remote_sig) - self.hm.channel_open_finished() - self.peer_state = peer_states.GOOD - self.set_state(channel_states.OPENING) + with self.db_lock: + self.config[REMOTE].current_per_commitment_point=remote_pcp + self.config[REMOTE].next_per_commitment_point=None + self.config[LOCAL].current_commitment_signature=remote_sig + self.hm.channel_open_finished() + self.peer_state = peer_states.GOOD + self.set_state(channel_states.OPENING) def set_state(self, state): """ set on-chain state """ @@ -279,7 +285,8 @@ class Channel(Logger): self._check_can_pay(htlc.amount_msat) if htlc.htlc_id is None: htlc = htlc._replace(htlc_id=self.hm.get_next_htlc_id(LOCAL)) - self.hm.send_htlc(htlc) + with self.db_lock: + self.hm.send_htlc(htlc) self.logger.info("add_htlc") return htlc @@ -300,7 +307,8 @@ class Channel(Logger): raise RemoteMisbehaving('Remote dipped below channel reserve.' +\ f' Available at remote: {self.available_to_spend(REMOTE)},' +\ f' HTLC amount: {htlc.amount_msat}') - self.hm.recv_htlc(htlc) + with self.db_lock: + self.hm.recv_htlc(htlc) self.logger.info("receive_htlc") return htlc @@ -346,9 +354,8 @@ class Channel(Logger): htlcsigs.append((ctx_output_idx, htlc_sig)) htlcsigs.sort() htlcsigs = [x[1] for x in htlcsigs] - - self.hm.send_ctx() - + with self.db_lock: + self.hm.send_ctx() return sig_64, htlcsigs def receive_new_commitment(self, sig, htlc_sigs): @@ -395,11 +402,10 @@ class Channel(Logger): pcp=pcp, ctx=pending_local_commitment, ctx_output_idx=ctx_output_idx) - - self.hm.recv_ctx() - self.config[LOCAL]=self.config[LOCAL]._replace( - current_commitment_signature=sig, - current_htlc_signatures=htlc_sigs_string) + with self.db_lock: + self.hm.recv_ctx() + self.config[LOCAL].current_commitment_signature=sig + self.config[LOCAL].current_htlc_signatures=htlc_sigs_string def verify_htlc(self, *, htlc: UpdateAddHtlc, htlc_sig: bytes, htlc_direction: Direction, pcp: bytes, ctx: Transaction, ctx_output_idx: int) -> None: @@ -429,7 +435,8 @@ class Channel(Logger): if not self.signature_fits(new_ctx): # this should never fail; as receive_new_commitment already did this test raise Exception("refusing to revoke as remote sig does not fit") - self.hm.send_rev() + with self.db_lock: + self.hm.send_rev() received = self.hm.received_in_ctn(new_ctn) sent = self.hm.sent_in_ctn(new_ctn) if self.lnworker: @@ -451,13 +458,12 @@ class Channel(Logger): if cur_point != derived_point: raise Exception('revoked secret not for current point') - self.config[REMOTE].revocation_store.add_next_entry(revocation.per_commitment_secret) - ##### start applying fee/htlc changes - self.hm.recv_rev() - self.config[REMOTE]=self.config[REMOTE]._replace( - current_per_commitment_point=self.config[REMOTE].next_per_commitment_point, - next_per_commitment_point=revocation.next_per_commitment_point, - ) + with self.db_lock: + self.revocation_store.add_next_entry(revocation.per_commitment_secret) + ##### start applying fee/htlc changes + self.hm.recv_rev() + self.config[REMOTE].current_per_commitment_point=self.config[REMOTE].next_per_commitment_point + self.config[REMOTE].next_per_commitment_point=revocation.next_per_commitment_point def balance(self, whose, *, ctx_owner=HTLCOwner.LOCAL, ctn=None): """ @@ -548,7 +554,7 @@ class Channel(Logger): secret = None point = conf.current_per_commitment_point else: - secret = conf.revocation_store.retrieve_secret(RevocationStore.START_INDEX - ctn) + secret = self.revocation_store.retrieve_secret(RevocationStore.START_INDEX - ctn) point = secret_to_pubkey(int.from_bytes(secret, 'big')) else: secret = get_per_commitment_secret_from_seed(self.config[LOCAL].per_commitment_secret_seed, RevocationStore.START_INDEX - ctn) @@ -624,15 +630,18 @@ class Channel(Logger): htlc = log['adds'][htlc_id] assert htlc.payment_hash == sha256(preimage) assert htlc_id not in log['settles'] - self.hm.recv_settle(htlc_id) + with self.db_lock: + self.hm.recv_settle(htlc_id) def fail_htlc(self, htlc_id): self.logger.info("fail_htlc") - self.hm.send_fail(htlc_id) + with self.db_lock: + self.hm.send_fail(htlc_id) def receive_fail_htlc(self, htlc_id): self.logger.info("receive_fail_htlc") - self.hm.recv_fail(htlc_id) + with self.db_lock: + self.hm.recv_fail(htlc_id) def pending_local_fee(self): return self.constraints.capacity - sum(x.value for x in self.get_next_commitment(LOCAL).outputs()) @@ -641,10 +650,11 @@ class Channel(Logger): # feerate uses sat/kw if self.constraints.is_initiator != from_us: raise Exception(f"Cannot update_fee: wrong initiator. us: {from_us}") - if from_us: - self.hm.send_update_fee(feerate) - else: - self.hm.recv_update_fee(feerate) + with self.db_lock: + if from_us: + self.hm.send_update_fee(feerate) + else: + self.hm.recv_update_fee(feerate) def to_save(self): to_save = { @@ -656,6 +666,7 @@ class Channel(Logger): "funding_outpoint": self.funding_outpoint, "node_id": self.node_id, "log": self.hm.to_save(), + "revocation_store": self.revocation_store, "onion_keys": str_bytes_dict_to_save(self.onion_keys), "state": self._state.name, "data_loss_protect_remote_pcp": str_bytes_dict_to_save(self.data_loss_protect_remote_pcp), diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py @@ -548,7 +548,6 @@ class Peer(Logger): if remote_to_self_delay > MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED: raise Exception(f"Remote Lightning peer reports to_self_delay={remote_to_self_delay}," + f" which is above Electrums required maximum ({MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED})") - their_revocation_store = RevocationStore() remote_config = RemoteConfig( payment_basepoint=OnlyPubkeyKeypair(payload['payment_basepoint']), multisig_key=OnlyPubkeyKeypair(payload["funding_pubkey"]), @@ -564,7 +563,6 @@ class Peer(Logger): htlc_minimum_msat = htlc_min, next_per_commitment_point=remote_per_commitment_point, current_per_commitment_point=None, - revocation_store=their_revocation_store, ) # replace dummy output in funding tx redeem_script = funding_output_script(local_config, remote_config) @@ -592,6 +590,7 @@ class Peer(Logger): "constraints": ChannelConstraints(capacity=funding_sat, is_initiator=True, funding_txn_minimum_depth=funding_txn_minimum_depth), "remote_update": None, "state": channel_states.PREOPENING.name, + "revocation_store": {}, } chan = Channel(chan_dict, sweep_address=self.lnworker.sweep_address, @@ -645,7 +644,6 @@ class Peer(Logger): funding_idx = int.from_bytes(funding_created['funding_output_index'], 'big') funding_txid = bh2u(funding_created['funding_txid'][::-1]) channel_id, funding_txid_bytes = channel_id_from_funding_tx(funding_txid, funding_idx) - their_revocation_store = RevocationStore() remote_balance_sat = funding_sat * 1000 - push_msat remote_dust_limit_sat = int.from_bytes(payload['dust_limit_satoshis'], byteorder='big') # TODO validate remote_reserve_sat = self.validate_remote_reserve(payload['channel_reserve_satoshis'], remote_dust_limit_sat, funding_sat) @@ -669,12 +667,12 @@ class Peer(Logger): htlc_minimum_msat=int.from_bytes(payload['htlc_minimum_msat'], 'big'), # TODO validate next_per_commitment_point=payload['first_per_commitment_point'], current_per_commitment_point=None, - revocation_store=their_revocation_store, ), "local_config": local_config, "constraints": ChannelConstraints(capacity=funding_sat, is_initiator=False, funding_txn_minimum_depth=min_depth), "remote_update": None, "state": channel_states.PREOPENING.name, + "revocation_store": {}, } chan = Channel(chan_dict, sweep_address=self.lnworker.sweep_address, @@ -740,9 +738,8 @@ class Peer(Logger): if oldest_unrevoked_remote_ctn == 0: last_rev_secret = 0 else: - revocation_store = chan.config[REMOTE].revocation_store last_rev_index = oldest_unrevoked_remote_ctn - 1 - last_rev_secret = revocation_store.retrieve_secret(RevocationStore.START_INDEX - last_rev_index) + last_rev_secret = chan.revocation_store.retrieve_secret(RevocationStore.START_INDEX - last_rev_index) latest_secret, latest_point = chan.get_secret_and_point(LOCAL, latest_local_ctn) self.send_message( "channel_reestablish", @@ -895,10 +892,8 @@ class Peer(Logger): if not chan.config[LOCAL].funding_locked_received: our_next_point = chan.config[REMOTE].next_per_commitment_point their_next_point = payload["next_per_commitment_point"] - new_remote_state = chan.config[REMOTE]._replace(next_per_commitment_point=their_next_point) - new_local_state = chan.config[LOCAL]._replace(funding_locked_received = True) - chan.config[REMOTE]=new_remote_state - chan.config[LOCAL]=new_local_state + chan.config[REMOTE].next_per_commitment_point = their_next_point + chan.config[LOCAL].funding_locked_received = True self.lnworker.save_channel(chan) if chan.short_channel_id: self.mark_open(chan) @@ -913,9 +908,9 @@ class Peer(Logger): # don't announce our channels # FIXME should this be a field in chan.local_state maybe? return - chan.config[LOCAL]=chan.config[LOCAL]._replace(was_announced=True) - coro = self.handle_announcements(chan) + chan.config[LOCAL].was_announced = True self.lnworker.save_channel(chan) + coro = self.handle_announcements(chan) asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop) @log_exceptions diff --git a/electrum/lnsweep.py b/electrum/lnsweep.py @@ -293,7 +293,7 @@ def analyze_ctx(chan: 'Channel', ctx: Transaction): is_revocation = False elif ctn < oldest_unrevoked_remote_ctn: # breach try: - per_commitment_secret = their_conf.revocation_store.retrieve_secret(RevocationStore.START_INDEX - ctn) + per_commitment_secret = chan.revocation_store.retrieve_secret(RevocationStore.START_INDEX - ctn) except UnableToDeriveSecret: return their_pcp = ecc.ECPrivkey(per_commitment_secret).get_public_key_bytes(compressed=True) diff --git a/electrum/lnutil.py b/electrum/lnutil.py @@ -7,6 +7,7 @@ import json from collections import namedtuple from typing import NamedTuple, List, Tuple, Mapping, Optional, TYPE_CHECKING, Union, Dict, Set, Sequence import re +import attr from aiorpcx import NetAddress @@ -37,55 +38,56 @@ LN_MAX_FUNDING_SAT = pow(2, 24) - 1 def ln_dummy_address(): return redeem_script_to_address('p2wsh', '') -class Keypair(NamedTuple): - pubkey: bytes - privkey: bytes - - -class OnlyPubkeyKeypair(NamedTuple): - pubkey: bytes - - -# NamedTuples cannot subclass NamedTuples :'( https://github.com/python/typing/issues/427 -class LocalConfig(NamedTuple): - # shared channel config fields (DUPLICATED code!!) - payment_basepoint: 'Keypair' - multisig_key: 'Keypair' - htlc_basepoint: 'Keypair' - delayed_basepoint: 'Keypair' - revocation_basepoint: 'Keypair' - to_self_delay: int - dust_limit_sat: int - max_htlc_value_in_flight_msat: int - max_accepted_htlcs: int - initial_msat: int - reserve_sat: int - # specific to "LOCAL" config - per_commitment_secret_seed: bytes - funding_locked_received: bool - was_announced: bool - current_commitment_signature: Optional[bytes] - current_htlc_signatures: bytes - - -class RemoteConfig(NamedTuple): - # shared channel config fields (DUPLICATED code!!) - payment_basepoint: Union['Keypair', 'OnlyPubkeyKeypair'] - multisig_key: Union['Keypair', 'OnlyPubkeyKeypair'] - htlc_basepoint: Union['Keypair', 'OnlyPubkeyKeypair'] - delayed_basepoint: Union['Keypair', 'OnlyPubkeyKeypair'] - revocation_basepoint: Union['Keypair', 'OnlyPubkeyKeypair'] - to_self_delay: int - dust_limit_sat: int - max_htlc_value_in_flight_msat: int - max_accepted_htlcs: int - initial_msat: int - reserve_sat: int - # specific to "REMOTE" config - htlc_minimum_msat: int - next_per_commitment_point: bytes - revocation_store: 'RevocationStore' - current_per_commitment_point: Optional[bytes] + +class StoredAttr: + + def to_json(self): + return dict(vars(self)) + + +@attr.s +class OnlyPubkeyKeypair(StoredAttr): + pubkey = attr.ib(type=bytes) + +@attr.s +class Keypair(OnlyPubkeyKeypair): + privkey = attr.ib(type=bytes) + +@attr.s +class Config(StoredAttr): + # shared channel config fields + payment_basepoint = attr.ib(type=OnlyPubkeyKeypair) + multisig_key = attr.ib(type=OnlyPubkeyKeypair) + htlc_basepoint = attr.ib(type=OnlyPubkeyKeypair) + delayed_basepoint = attr.ib(type=OnlyPubkeyKeypair) + revocation_basepoint = attr.ib(type=OnlyPubkeyKeypair) + to_self_delay = attr.ib(type=int) + dust_limit_sat = attr.ib(type=int) + max_htlc_value_in_flight_msat = attr.ib(type=int) + max_accepted_htlcs = attr.ib(type=int) + initial_msat = attr.ib(type=int) + reserve_sat = attr.ib(type=int) + +@attr.s +class LocalConfig(Config): + per_commitment_secret_seed = attr.ib(type=bytes) + funding_locked_received = attr.ib(type=bool) + was_announced = attr.ib(type=bool) + current_commitment_signature = attr.ib(type=bytes) + current_htlc_signatures = attr.ib(type=bytes) + +@attr.s +class RemoteConfig(Config): + htlc_minimum_msat = attr.ib(type=int) + next_per_commitment_point = attr.ib(type=bytes) + current_per_commitment_point = attr.ib(default=None, type=bytes) + +#@attr.s +#class FeeUpdate(StoredAttr): +# rate = attr.ib(type=int) # in sat/kw +# ctn_local = attr.ib(default=None, type=int) +# ctn_remote = attr.ib(default=None, type=int) + class FeeUpdate(NamedTuple): @@ -187,9 +189,13 @@ class RevocationStore: START_INDEX = 2 ** 48 - 1 - def __init__(self): + def __init__(self, storage): self.buckets = [None] * 49 self.index = self.START_INDEX + if storage: + decode = lambda to_decode: ShachainElement(bfh(to_decode[0]), int(to_decode[1])) + self.buckets = [k if k is None else decode(k) for k in storage["buckets"]] + self.index = storage["index"] def add_next_entry(self, hsh): new_element = ShachainElement(index=self.index, secret=hsh) @@ -197,7 +203,6 @@ class RevocationStore: for i in range(0, bucket): this_bucket = self.buckets[i] e = shachain_derive(new_element, this_bucket.index) - if e != this_bucket: raise Exception("hash is not derivable: {} {} {}".format(bh2u(e.secret), bh2u(this_bucket.secret), this_bucket.index)) self.buckets[bucket] = new_element @@ -218,14 +223,6 @@ class RevocationStore: def serialize(self): return {"index": self.index, "buckets": [[bh2u(k.secret), k.index] if k is not None else None for k in self.buckets]} - @staticmethod - def from_json_obj(decoded_json_obj): - store = RevocationStore() - decode = lambda to_decode: ShachainElement(bfh(to_decode[0]), int(to_decode[1])) - store.buckets = [k if k is None else decode(k) for k in decoded_json_obj["buckets"]] - store.index = decoded_json_obj["index"] - return store - def __eq__(self, o): return type(o) is RevocationStore and self.serialize() == o.serialize() diff --git a/electrum/tests/test_lnchannel.py b/electrum/tests/test_lnchannel.py @@ -45,7 +45,6 @@ def create_channel_state(funding_txid, funding_index, funding_sat, is_initiator, assert local_amount > 0 assert remote_amount > 0 channel_id, _ = lnpeer.channel_id_from_funding_tx(funding_txid, funding_index) - their_revocation_store = lnpeer.RevocationStore() return { "channel_id":channel_id, @@ -67,7 +66,6 @@ def create_channel_state(funding_txid, funding_index, funding_sat, is_initiator, next_per_commitment_point=nex, current_per_commitment_point=cur, - revocation_store=their_revocation_store, ), "local_config":lnpeer.LocalConfig( payment_basepoint=privkeys[0], @@ -96,6 +94,7 @@ def create_channel_state(funding_txid, funding_index, funding_sat, is_initiator, "node_id":other_node_id, 'onion_keys': {}, 'state': 'PREOPENING', + 'revocation_store': {}, } def bip32(sequence): @@ -151,14 +150,16 @@ def create_test_channels(feerate=6000, local=None, remote=None): assert len(a_htlc_sigs) == 0 assert len(b_htlc_sigs) == 0 - alice.config[LOCAL] = alice.config[LOCAL]._replace(current_commitment_signature=sig_from_bob) - bob.config[LOCAL] = bob.config[LOCAL]._replace(current_commitment_signature=sig_from_alice) + alice.config[LOCAL].current_commitment_signature = sig_from_bob + bob.config[LOCAL].current_commitment_signature = sig_from_alice alice_second = lnutil.secret_to_pubkey(int.from_bytes(lnutil.get_per_commitment_secret_from_seed(alice_seed, lnutil.RevocationStore.START_INDEX - 1), "big")) bob_second = lnutil.secret_to_pubkey(int.from_bytes(lnutil.get_per_commitment_secret_from_seed(bob_seed, lnutil.RevocationStore.START_INDEX - 1), "big")) - alice.config[REMOTE] = alice.config[REMOTE]._replace(next_per_commitment_point=bob_second, current_per_commitment_point=bob_first) - bob.config[REMOTE] = bob.config[REMOTE]._replace(next_per_commitment_point=alice_second, current_per_commitment_point=alice_first) + alice.config[REMOTE].next_per_commitment_point = bob_second + alice.config[REMOTE].current_per_commitment_point = bob_first + bob.config[REMOTE].next_per_commitment_point = alice_second + bob.config[REMOTE].current_per_commitment_point = alice_first alice.hm.channel_open_finished() bob.hm.channel_open_finished() @@ -663,15 +664,11 @@ class TestChanReserve(ElectrumTestCase): bob_min_reserve = 6 * one_bitcoin_in_msat // 1000 # bob min reserve was decided by alice, but applies to bob - alice_channel.config[LOCAL] =\ - alice_channel.config[LOCAL]._replace(reserve_sat=bob_min_reserve) - alice_channel.config[REMOTE] =\ - alice_channel.config[REMOTE]._replace(reserve_sat=alice_min_reserve) + alice_channel.config[LOCAL].reserve_sat = bob_min_reserve + alice_channel.config[REMOTE].reserve_sat = alice_min_reserve - bob_channel.config[LOCAL] =\ - bob_channel.config[LOCAL]._replace(reserve_sat=alice_min_reserve) - bob_channel.config[REMOTE] =\ - bob_channel.config[REMOTE]._replace(reserve_sat=bob_min_reserve) + bob_channel.config[LOCAL].reserve_sat = alice_min_reserve + bob_channel.config[REMOTE].reserve_sat = bob_min_reserve self.alice_channel = alice_channel self.bob_channel = bob_channel diff --git a/electrum/tests/test_lnutil.py b/electrum/tests/test_lnutil.py @@ -422,7 +422,7 @@ class TestLNUtil(ElectrumTestCase): ] for test in tests: - receiver = RevocationStore() + receiver = RevocationStore({}) for insert in test["inserts"]: secret = bytes.fromhex(insert["secret"]) @@ -445,14 +445,14 @@ class TestLNUtil(ElectrumTestCase): def test_shachain_produce_consume(self): seed = bitcoin.sha256(b"shachaintest") - consumer = RevocationStore() + consumer = RevocationStore({}) for i in range(10000): secret = get_per_commitment_secret_from_seed(seed, RevocationStore.START_INDEX - i) try: consumer.add_next_entry(secret) except Exception as e: raise Exception("iteration " + str(i) + ": " + str(e)) - if i % 1000 == 0: self.assertEqual(consumer.serialize(), RevocationStore.from_json_obj(json.loads(json.dumps(consumer.serialize()))).serialize()) + if i % 1000 == 0: self.assertEqual(consumer.serialize(), RevocationStore(json.loads(json.dumps(consumer.serialize()))).serialize()) def test_commitment_tx_with_all_five_HTLCs_untrimmed_minimum_feerate(self): to_local_msat = 6988000000