469 lines
19 KiB
Python
469 lines
19 KiB
Python
import logging
|
|
import time
|
|
from typing import Any, Dict, List, Optional, Set
|
|
|
|
from blspy import G1Element
|
|
|
|
from chia.consensus.cost_calculator import calculate_cost_of_program, NPCResult
|
|
from chia.full_node.bundle_tools import simple_solution_generator
|
|
from chia.full_node.mempool_check_conditions import get_name_puzzle_conditions
|
|
from chia.types.blockchain_format.coin import Coin
|
|
from chia.types.blockchain_format.program import Program, SerializedProgram
|
|
from chia.types.announcement import Announcement
|
|
from chia.types.blockchain_format.sized_bytes import bytes32
|
|
from chia.types.coin_solution import CoinSolution
|
|
from chia.types.generator_types import BlockGenerator
|
|
from chia.types.spend_bundle import SpendBundle
|
|
from chia.util.ints import uint8, uint32, uint64, uint128
|
|
from chia.util.hash import std_hash
|
|
from chia.wallet.derivation_record import DerivationRecord
|
|
from chia.wallet.puzzles.p2_delegated_puzzle_or_hidden_puzzle import (
|
|
DEFAULT_HIDDEN_PUZZLE_HASH,
|
|
calculate_synthetic_secret_key,
|
|
puzzle_for_pk,
|
|
solution_for_conditions,
|
|
)
|
|
from chia.wallet.puzzles.puzzle_utils import (
|
|
make_assert_coin_announcement,
|
|
make_assert_puzzle_announcement,
|
|
make_assert_my_coin_id_condition,
|
|
make_assert_absolute_seconds_exceeds_condition,
|
|
make_create_coin_announcement,
|
|
make_create_puzzle_announcement,
|
|
make_create_coin_condition,
|
|
make_reserve_fee_condition,
|
|
)
|
|
from chia.wallet.secret_key_store import SecretKeyStore
|
|
from chia.wallet.sign_coin_solutions import sign_coin_solutions
|
|
from chia.wallet.transaction_record import TransactionRecord
|
|
from chia.wallet.util.transaction_type import TransactionType
|
|
from chia.wallet.util.wallet_types import WalletType
|
|
from chia.wallet.wallet_coin_record import WalletCoinRecord
|
|
from chia.wallet.wallet_info import WalletInfo
|
|
|
|
|
|
class Wallet:
|
|
wallet_state_manager: Any
|
|
log: logging.Logger
|
|
wallet_id: uint32
|
|
secret_key_store: SecretKeyStore
|
|
cost_of_single_tx: Optional[int]
|
|
|
|
@staticmethod
|
|
async def create(
|
|
wallet_state_manager: Any,
|
|
info: WalletInfo,
|
|
name: str = None,
|
|
):
|
|
self = Wallet()
|
|
if name:
|
|
self.log = logging.getLogger(name)
|
|
else:
|
|
self.log = logging.getLogger(__name__)
|
|
self.wallet_state_manager = wallet_state_manager
|
|
self.wallet_id = info.id
|
|
self.secret_key_store = SecretKeyStore()
|
|
self.cost_of_single_tx = None
|
|
return self
|
|
|
|
async def get_max_send_amount(self, records=None):
|
|
spendable: List[WalletCoinRecord] = list(
|
|
await self.wallet_state_manager.get_spendable_coins_for_wallet(self.id(), records)
|
|
)
|
|
if len(spendable) == 0:
|
|
return 0
|
|
spendable.sort(reverse=True, key=lambda record: record.coin.amount)
|
|
if self.cost_of_single_tx is None:
|
|
coin = spendable[0].coin
|
|
tx = await self.generate_signed_transaction(
|
|
coin.amount, coin.puzzle_hash, coins={coin}, ignore_max_send_amount=True
|
|
)
|
|
program: BlockGenerator = simple_solution_generator(tx.spend_bundle)
|
|
# npc contains names of the coins removed, puzzle_hashes and their spend conditions
|
|
result: NPCResult = get_name_puzzle_conditions(
|
|
program, self.wallet_state_manager.constants.MAX_BLOCK_COST_CLVM, True
|
|
)
|
|
cost_result: uint64 = calculate_cost_of_program(
|
|
program.program, result, self.wallet_state_manager.constants.COST_PER_BYTE
|
|
)
|
|
self.cost_of_single_tx = cost_result
|
|
self.log.info(f"Cost of a single tx for standard wallet: {self.cost_of_single_tx}")
|
|
|
|
max_cost = self.wallet_state_manager.constants.MAX_BLOCK_COST_CLVM / 5 # avoid full block TXs
|
|
current_cost = 0
|
|
total_amount = 0
|
|
total_coin_count = 0
|
|
for record in spendable:
|
|
current_cost += self.cost_of_single_tx
|
|
total_amount += record.coin.amount
|
|
total_coin_count += 1
|
|
if current_cost + self.cost_of_single_tx > max_cost:
|
|
break
|
|
|
|
return total_amount
|
|
|
|
@classmethod
|
|
def type(cls) -> uint8:
|
|
return uint8(WalletType.STANDARD_WALLET)
|
|
|
|
def id(self) -> uint32:
|
|
return self.wallet_id
|
|
|
|
async def get_confirmed_balance(self, unspent_records=None) -> uint128:
|
|
return await self.wallet_state_manager.get_confirmed_balance_for_wallet(self.id(), unspent_records)
|
|
|
|
async def get_unconfirmed_balance(self, unspent_records=None) -> uint128:
|
|
return await self.wallet_state_manager.get_unconfirmed_balance(self.id(), unspent_records)
|
|
|
|
async def get_spendable_balance(self, unspent_records=None) -> uint128:
|
|
spendable = await self.wallet_state_manager.get_confirmed_spendable_balance_for_wallet(
|
|
self.id(), unspent_records
|
|
)
|
|
return spendable
|
|
|
|
async def get_pending_change_balance(self) -> uint64:
|
|
unconfirmed_tx = await self.wallet_state_manager.tx_store.get_unconfirmed_for_wallet(self.id())
|
|
addition_amount = 0
|
|
|
|
for record in unconfirmed_tx:
|
|
if not record.is_in_mempool():
|
|
self.log.warning(f"Record: {record} not in mempool")
|
|
continue
|
|
our_spend = False
|
|
for coin in record.removals:
|
|
if await self.wallet_state_manager.does_coin_belong_to_wallet(coin, self.id()):
|
|
our_spend = True
|
|
break
|
|
|
|
if our_spend is not True:
|
|
continue
|
|
|
|
for coin in record.additions:
|
|
if await self.wallet_state_manager.does_coin_belong_to_wallet(coin, self.id()):
|
|
addition_amount += coin.amount
|
|
|
|
return uint64(addition_amount)
|
|
|
|
def puzzle_for_pk(self, pubkey: bytes) -> Program:
|
|
return puzzle_for_pk(pubkey)
|
|
|
|
async def hack_populate_secret_key_for_puzzle_hash(self, puzzle_hash: bytes32) -> G1Element:
|
|
maybe = await self.wallet_state_manager.get_keys(puzzle_hash)
|
|
if maybe is None:
|
|
error_msg = f"Wallet couldn't find keys for puzzle_hash {puzzle_hash}"
|
|
self.log.error(error_msg)
|
|
raise ValueError(error_msg)
|
|
|
|
# Get puzzle for pubkey
|
|
public_key, secret_key = maybe
|
|
|
|
# HACK
|
|
synthetic_secret_key = calculate_synthetic_secret_key(secret_key, DEFAULT_HIDDEN_PUZZLE_HASH)
|
|
self.secret_key_store.save_secret_key(synthetic_secret_key)
|
|
|
|
return public_key
|
|
|
|
async def hack_populate_secret_keys_for_coin_solutions(self, coin_solutions: List[CoinSolution]) -> None:
|
|
"""
|
|
This hack forces secret keys into the `_pk2sk` lookup. This should eventually be replaced
|
|
by a persistent DB table that can do this look-up directly.
|
|
"""
|
|
for coin_solution in coin_solutions:
|
|
await self.hack_populate_secret_key_for_puzzle_hash(coin_solution.coin.puzzle_hash)
|
|
|
|
async def puzzle_for_puzzle_hash(self, puzzle_hash: bytes32) -> Program:
|
|
public_key = await self.hack_populate_secret_key_for_puzzle_hash(puzzle_hash)
|
|
return puzzle_for_pk(bytes(public_key))
|
|
|
|
async def get_new_puzzle(self) -> Program:
|
|
dr = await self.wallet_state_manager.get_unused_derivation_record(self.id())
|
|
return puzzle_for_pk(bytes(dr.pubkey))
|
|
|
|
async def get_puzzle_hash(self, new: bool):
|
|
if new:
|
|
return await self.get_new_puzzlehash()
|
|
else:
|
|
record: Optional[
|
|
DerivationRecord
|
|
] = await self.wallet_state_manager.get_current_derivation_record_for_wallet(self.id())
|
|
if record is None:
|
|
return await self.get_new_puzzlehash()
|
|
return record.puzzle_hash
|
|
|
|
async def get_new_puzzlehash(self) -> bytes32:
|
|
return (await self.wallet_state_manager.get_unused_derivation_record(self.id())).puzzle_hash
|
|
|
|
def make_solution(
|
|
self,
|
|
primaries: Optional[List[Dict[str, Any]]] = None,
|
|
min_time=0,
|
|
me=None,
|
|
coin_announcements: Optional[List[bytes32]] = None,
|
|
coin_announcements_to_assert: Optional[List[bytes32]] = None,
|
|
puzzle_announcements=None,
|
|
puzzle_announcements_to_assert=None,
|
|
fee=0,
|
|
) -> Program:
|
|
assert fee >= 0
|
|
condition_list = []
|
|
if primaries:
|
|
for primary in primaries:
|
|
condition_list.append(make_create_coin_condition(primary["puzzlehash"], primary["amount"]))
|
|
if min_time > 0:
|
|
condition_list.append(make_assert_absolute_seconds_exceeds_condition(min_time))
|
|
if me:
|
|
condition_list.append(make_assert_my_coin_id_condition(me["id"]))
|
|
if fee:
|
|
condition_list.append(make_reserve_fee_condition(fee))
|
|
if coin_announcements:
|
|
for announcement in coin_announcements:
|
|
condition_list.append(make_create_coin_announcement(announcement))
|
|
if coin_announcements_to_assert:
|
|
for announcement_hash in coin_announcements_to_assert:
|
|
condition_list.append(make_assert_coin_announcement(announcement_hash))
|
|
if puzzle_announcements:
|
|
for announcement in puzzle_announcements:
|
|
condition_list.append(make_create_puzzle_announcement(announcement))
|
|
if puzzle_announcements_to_assert:
|
|
for announcement_hash in puzzle_announcements_to_assert:
|
|
condition_list.append(make_assert_puzzle_announcement(announcement_hash))
|
|
return solution_for_conditions(condition_list)
|
|
|
|
async def select_coins(self, amount, exclude: List[Coin] = None) -> Set[Coin]:
|
|
"""
|
|
Returns a set of coins that can be used for generating a new transaction.
|
|
Note: This must be called under a wallet state manager lock
|
|
"""
|
|
if exclude is None:
|
|
exclude = []
|
|
|
|
spendable_amount = await self.get_spendable_balance()
|
|
|
|
if amount > spendable_amount:
|
|
error_msg = (
|
|
f"Can't select amount higher than our spendable balance. Amount: {amount}, spendable: "
|
|
f" {spendable_amount}"
|
|
)
|
|
self.log.warning(error_msg)
|
|
raise ValueError(error_msg)
|
|
|
|
self.log.info(f"About to select coins for amount {amount}")
|
|
unspent: List[WalletCoinRecord] = list(
|
|
await self.wallet_state_manager.get_spendable_coins_for_wallet(self.id())
|
|
)
|
|
sum_value = 0
|
|
used_coins: Set = set()
|
|
|
|
# Use older coins first
|
|
unspent.sort(reverse=True, key=lambda r: r.coin.amount)
|
|
|
|
# Try to use coins from the store, if there isn't enough of "unused"
|
|
# coins use change coins that are not confirmed yet
|
|
unconfirmed_removals: Dict[bytes32, Coin] = await self.wallet_state_manager.unconfirmed_removals_for_wallet(
|
|
self.id()
|
|
)
|
|
for coinrecord in unspent:
|
|
if sum_value >= amount and len(used_coins) > 0:
|
|
break
|
|
if coinrecord.coin.name() in unconfirmed_removals:
|
|
continue
|
|
if coinrecord.coin in exclude:
|
|
continue
|
|
sum_value += coinrecord.coin.amount
|
|
used_coins.add(coinrecord.coin)
|
|
self.log.debug(f"Selected coin: {coinrecord.coin.name()} at height {coinrecord.confirmed_block_height}!")
|
|
|
|
# This happens when we couldn't use one of the coins because it's already used
|
|
# but unconfirmed, and we are waiting for the change. (unconfirmed_additions)
|
|
if sum_value < amount:
|
|
raise ValueError(
|
|
"Can't make this transaction at the moment. Waiting for the change from the previous transaction."
|
|
)
|
|
|
|
self.log.debug(f"Successfully selected coins: {used_coins}")
|
|
return used_coins
|
|
|
|
async def _generate_unsigned_transaction(
|
|
self,
|
|
amount: uint64,
|
|
newpuzzlehash: bytes32,
|
|
fee: uint64 = uint64(0),
|
|
origin_id: bytes32 = None,
|
|
coins: Set[Coin] = None,
|
|
primaries_input: Optional[List[Dict[str, Any]]] = None,
|
|
ignore_max_send_amount: bool = False,
|
|
) -> List[CoinSolution]:
|
|
"""
|
|
Generates a unsigned transaction in form of List(Puzzle, Solutions)
|
|
Note: this must be called under a wallet state manager lock
|
|
"""
|
|
if primaries_input is None:
|
|
primaries: Optional[List[Dict]] = None
|
|
total_amount = amount + fee
|
|
else:
|
|
primaries = primaries_input.copy()
|
|
primaries_amount = 0
|
|
for prim in primaries:
|
|
primaries_amount += prim["amount"]
|
|
total_amount = amount + fee + primaries_amount
|
|
|
|
if not ignore_max_send_amount:
|
|
max_send = await self.get_max_send_amount()
|
|
if total_amount > max_send:
|
|
raise ValueError(f"Can't send more than {max_send} in a single transaction")
|
|
|
|
if coins is None:
|
|
coins = await self.select_coins(total_amount)
|
|
assert len(coins) > 0
|
|
|
|
self.log.info(f"coins is not None {coins}")
|
|
spend_value = sum([coin.amount for coin in coins])
|
|
change = spend_value - total_amount
|
|
assert change >= 0
|
|
|
|
spends: List[CoinSolution] = []
|
|
primary_announcement_hash: Optional[bytes32] = None
|
|
|
|
# Check for duplicates
|
|
if primaries is not None:
|
|
all_primaries_list = [(p["puzzlehash"], p["amount"]) for p in primaries] + [(newpuzzlehash, amount)]
|
|
if len(set(all_primaries_list)) != len(all_primaries_list):
|
|
raise ValueError("Cannot create two identical coins")
|
|
|
|
for coin in coins:
|
|
self.log.info(f"coin from coins {coin}")
|
|
puzzle: Program = await self.puzzle_for_puzzle_hash(coin.puzzle_hash)
|
|
|
|
# Only one coin creates outputs
|
|
if primary_announcement_hash is None and origin_id in (None, coin.name()):
|
|
if primaries is None:
|
|
primaries = [{"puzzlehash": newpuzzlehash, "amount": amount}]
|
|
else:
|
|
primaries.append({"puzzlehash": newpuzzlehash, "amount": amount})
|
|
if change > 0:
|
|
change_puzzle_hash: bytes32 = await self.get_new_puzzlehash()
|
|
primaries.append({"puzzlehash": change_puzzle_hash, "amount": change})
|
|
message_list: List[bytes32] = [c.name() for c in coins]
|
|
for primary in primaries:
|
|
message_list.append(Coin(coin.name(), primary["puzzlehash"], primary["amount"]).name())
|
|
message: bytes32 = std_hash(b"".join(message_list))
|
|
solution: Program = self.make_solution(primaries=primaries, fee=fee, coin_announcements=[message])
|
|
primary_announcement_hash = Announcement(coin.name(), message).name()
|
|
else:
|
|
solution = self.make_solution(coin_announcements_to_assert=[primary_announcement_hash])
|
|
|
|
spends.append(
|
|
CoinSolution(
|
|
coin, SerializedProgram.from_bytes(bytes(puzzle)), SerializedProgram.from_bytes(bytes(solution))
|
|
)
|
|
)
|
|
|
|
self.log.info(f"Spends is {spends}")
|
|
return spends
|
|
|
|
async def sign_transaction(self, coin_solutions: List[CoinSolution]) -> SpendBundle:
|
|
return await sign_coin_solutions(
|
|
coin_solutions,
|
|
self.secret_key_store.secret_key_for_public_key,
|
|
self.wallet_state_manager.constants.AGG_SIG_ME_ADDITIONAL_DATA,
|
|
self.wallet_state_manager.constants.MAX_BLOCK_COST_CLVM,
|
|
)
|
|
|
|
async def generate_signed_transaction(
|
|
self,
|
|
amount: uint64,
|
|
puzzle_hash: bytes32,
|
|
fee: uint64 = uint64(0),
|
|
origin_id: bytes32 = None,
|
|
coins: Set[Coin] = None,
|
|
primaries: Optional[List[Dict[str, bytes32]]] = None,
|
|
ignore_max_send_amount: bool = False,
|
|
) -> TransactionRecord:
|
|
"""
|
|
Use this to generate transaction.
|
|
Note: this must be called under a wallet state manager lock
|
|
"""
|
|
if primaries is None:
|
|
non_change_amount = amount
|
|
else:
|
|
non_change_amount = uint64(amount + sum(p["amount"] for p in primaries))
|
|
|
|
transaction = await self._generate_unsigned_transaction(
|
|
amount, puzzle_hash, fee, origin_id, coins, primaries, ignore_max_send_amount
|
|
)
|
|
assert len(transaction) > 0
|
|
|
|
self.log.info("About to sign a transaction")
|
|
await self.hack_populate_secret_keys_for_coin_solutions(transaction)
|
|
spend_bundle: SpendBundle = await sign_coin_solutions(
|
|
transaction,
|
|
self.secret_key_store.secret_key_for_public_key,
|
|
self.wallet_state_manager.constants.AGG_SIG_ME_ADDITIONAL_DATA,
|
|
self.wallet_state_manager.constants.MAX_BLOCK_COST_CLVM,
|
|
)
|
|
|
|
now = uint64(int(time.time()))
|
|
add_list: List[Coin] = list(spend_bundle.additions())
|
|
rem_list: List[Coin] = list(spend_bundle.removals())
|
|
assert sum(a.amount for a in add_list) + fee == sum(r.amount for r in rem_list)
|
|
|
|
return TransactionRecord(
|
|
confirmed_at_height=uint32(0),
|
|
created_at_time=now,
|
|
to_puzzle_hash=puzzle_hash,
|
|
amount=uint64(non_change_amount),
|
|
fee_amount=uint64(fee),
|
|
confirmed=False,
|
|
sent=uint32(0),
|
|
spend_bundle=spend_bundle,
|
|
additions=add_list,
|
|
removals=rem_list,
|
|
wallet_id=self.id(),
|
|
sent_to=[],
|
|
trade_id=None,
|
|
type=uint32(TransactionType.OUTGOING_TX.value),
|
|
name=spend_bundle.name(),
|
|
)
|
|
|
|
async def push_transaction(self, tx: TransactionRecord) -> None:
|
|
"""Use this API to send transactions."""
|
|
await self.wallet_state_manager.add_pending_transaction(tx)
|
|
|
|
# This is to be aggregated together with a coloured coin offer to ensure that the trade happens
|
|
async def create_spend_bundle_relative_chia(self, chia_amount: int, exclude: List[Coin]) -> SpendBundle:
|
|
list_of_solutions = []
|
|
utxos = None
|
|
|
|
# If we're losing value then get coins with at least that much value
|
|
# If we're gaining value then our amount doesn't matter
|
|
if chia_amount < 0:
|
|
utxos = await self.select_coins(abs(chia_amount), exclude)
|
|
else:
|
|
utxos = await self.select_coins(0, exclude)
|
|
|
|
assert len(utxos) > 0
|
|
|
|
# Calculate output amount given sum of utxos
|
|
spend_value = sum([coin.amount for coin in utxos])
|
|
chia_amount = spend_value + chia_amount
|
|
|
|
# Create coin solutions for each utxo
|
|
output_created = None
|
|
for coin in utxos:
|
|
puzzle = await self.puzzle_for_puzzle_hash(coin.puzzle_hash)
|
|
if output_created is None:
|
|
newpuzhash = await self.get_new_puzzlehash()
|
|
primaries = [{"puzzlehash": newpuzhash, "amount": chia_amount}]
|
|
solution = self.make_solution(primaries=primaries)
|
|
output_created = coin
|
|
list_of_solutions.append(CoinSolution(coin, puzzle, solution))
|
|
|
|
await self.hack_populate_secret_keys_for_coin_solutions(list_of_solutions)
|
|
spend_bundle = await sign_coin_solutions(
|
|
list_of_solutions,
|
|
self.secret_key_store.secret_key_for_public_key,
|
|
self.wallet_state_manager.constants.AGG_SIG_ME_ADDITIONAL_DATA,
|
|
self.wallet_state_manager.constants.MAX_BLOCK_COST_CLVM,
|
|
)
|
|
return spend_bundle
|