Files
chia-blockchain/chia/data_layer/data_layer_wallet.py
T

1319 lines
55 KiB
Python

from __future__ import annotations
import dataclasses
import logging
from typing import TYPE_CHECKING, Any, ClassVar, cast
from chia_rs import BlockRecord, CoinSpend, CoinState, G1Element, G2Element
from chia_rs.datalayer import ProofOfInclusion, ProofOfInclusionLayer
from chia_rs.sized_bytes import bytes32
from chia_rs.sized_ints import uint8, uint32, uint64, uint128
from clvm.EvalError import EvalError
from typing_extensions import Unpack, final
from chia.data_layer.data_layer_errors import LauncherCoinNotFoundError, OfferIntegrityError
from chia.data_layer.data_layer_util import OfferStore, StoreProofs, leaf_hash
from chia.data_layer.singleton_record import SingletonRecord
from chia.server.ws_connection import WSChiaConnection
from chia.types.blockchain_format.coin import Coin
from chia.types.blockchain_format.program import Program, run_with_cost
from chia.types.coin_spend import make_spend
from chia.types.condition_opcodes import ConditionOpcode
from chia.util.streamable import Streamable, streamable
from chia.wallet.conditions import (
AssertAnnouncement,
AssertCoinAnnouncement,
AssertPuzzleAnnouncement,
Condition,
CreateCoin,
CreateCoinAnnouncement,
UnknownCondition,
)
from chia.wallet.db_wallet.db_wallet_puzzles import (
ACS_MU,
ACS_MU_PH,
GRAFTROOT_DL_OFFERS,
MIRROR_PUZZLE_HASH,
create_graftroot_offer_puz,
create_host_fullpuz,
create_host_layer_puzzle,
create_mirror_puzzle,
get_mirror_info,
launch_solution_to_singleton_info,
launcher_to_struct,
match_dl_singleton,
)
from chia.wallet.derivation_record import DerivationRecord
from chia.wallet.lineage_proof import LineageProof
from chia.wallet.outer_puzzles import AssetType
from chia.wallet.puzzle_drivers import PuzzleInfo, Solver
from chia.wallet.singleton import SINGLETON_LAUNCHER_PUZZLE, SINGLETON_LAUNCHER_PUZZLE_HASH
from chia.wallet.trading.offer import NotarizedPayment, Offer
from chia.wallet.transaction_record import TransactionRecord
from chia.wallet.util.compute_additions import compute_additions
from chia.wallet.util.merkle_utils import _simplify_merkle_proof
from chia.wallet.util.wallet_sync_utils import fetch_coin_spend, fetch_coin_spend_for_coin_state
from chia.wallet.util.wallet_types import WalletIdentifier, WalletType
from chia.wallet.wallet import Wallet
from chia.wallet.wallet_action_scope import WalletActionScope
from chia.wallet.wallet_coin_record import WalletCoinRecord
from chia.wallet.wallet_info import WalletInfo
from chia.wallet.wallet_protocol import GSTOptionalArgs, WalletProtocol
from chia.wallet.wallet_spend_bundle import WalletSpendBundle
if TYPE_CHECKING:
from chia.wallet.wallet_state_manager import WalletStateManager
@streamable
@dataclasses.dataclass(frozen=True)
class Mirror(Streamable):
coin_id: bytes32
launcher_id: bytes32
amount: uint64
urls: list[str]
ours: bool
confirmed_at_height: uint32 | None
@staticmethod
def encode_urls(urls: list[str]) -> list[bytes]:
return [url.encode("utf8") for url in urls]
@staticmethod
def decode_urls(urls: list[bytes]) -> list[str]:
return [url.decode("utf8") for url in urls]
@streamable
@dataclasses.dataclass(frozen=True)
class SingletonDependencies(Streamable):
launcher_id: bytes32
values_to_prove: list[bytes]
@streamable
@dataclasses.dataclass(frozen=True)
class SingletonSummary(Streamable):
launcher_id: bytes32
new_root: bytes32
dependencies: list[SingletonDependencies]
@streamable
@dataclasses.dataclass(frozen=True)
class DataLayerSummary(Streamable):
offered: list[SingletonSummary]
@final
class DataLayerWallet:
if TYPE_CHECKING:
# TODO Create DataLayer coin data model if necessary
_protocol_check: ClassVar[WalletProtocol] = cast("DataLayerWallet", None)
wallet_state_manager: WalletStateManager
log: logging.Logger
wallet_info: WalletInfo
wallet_id: uint8
standard_wallet: Wallet
"""
interface used by datalayer for interacting with the chain
"""
@classmethod
async def create(
cls,
wallet_state_manager: WalletStateManager,
wallet_info: WalletInfo,
) -> DataLayerWallet:
self = cls()
self.wallet_state_manager = wallet_state_manager
self.log = logging.getLogger(__name__)
self.standard_wallet = wallet_state_manager.main_wallet
self.wallet_info = wallet_info
self.wallet_id = uint8(self.wallet_info.id)
return self
@classmethod
def type(cls) -> WalletType:
return WalletType.DATA_LAYER
def id(self) -> uint32:
return self.wallet_info.id
@classmethod
async def create_new_dl_wallet(cls, wallet_state_manager: WalletStateManager) -> DataLayerWallet:
"""
This must be called under the wallet state manager lock
"""
self = cls()
self.wallet_state_manager = wallet_state_manager
self.log = logging.getLogger(__name__)
self.standard_wallet = wallet_state_manager.main_wallet
for _, w in self.wallet_state_manager.wallets.items():
if w.type() == WalletType.DATA_LAYER:
raise ValueError("DataLayer Wallet already exists for this key")
self.wallet_info = await wallet_state_manager.user_store.create_wallet(
"DataLayer Wallet",
WalletType.DATA_LAYER.value,
"",
)
await self.wallet_state_manager.add_new_wallet(self)
return self
#############
# LAUNCHING #
#############
@staticmethod
async def match_dl_launcher(launcher_spend: CoinSpend) -> tuple[bool, bytes32 | None]:
# Sanity check it's a launcher
if launcher_spend.puzzle_reveal != SINGLETON_LAUNCHER_PUZZLE.to_serialized():
return False, None
# Let's make sure the solution looks how we expect it to
try:
full_puzhash, amount, root, inner_puzhash = launch_solution_to_singleton_info(
Program.from_serialized(launcher_spend.solution)
)
except ValueError:
return False, None
# Now let's check that the full puzzle is an odd data layer singleton
if (
full_puzhash
!= create_host_fullpuz(inner_puzhash, root, launcher_spend.coin.name()).get_tree_hash_precalc(inner_puzhash)
or amount % 2 == 0
):
return False, None
return True, inner_puzhash
async def get_launcher_coin_state(self, launcher_id: bytes32, peer: WSChiaConnection) -> CoinState:
coin_states: list[CoinState] = await self.wallet_state_manager.wallet_node.get_coin_state(
[launcher_id], peer=peer
)
if len(coin_states) == 0:
raise LauncherCoinNotFoundError(f"Launcher ID {launcher_id} is not a valid coin")
if coin_states[0].coin.puzzle_hash != SINGLETON_LAUNCHER_PUZZLE_HASH:
raise ValueError(f"Coin with ID {launcher_id} is not a singleton launcher")
if coin_states[0].created_height is None:
raise ValueError(f"Launcher with ID {launcher_id} has not been created (maybe reorged)")
if coin_states[0].spent_height is None:
raise ValueError(f"Launcher with ID {launcher_id} has not been spent")
return coin_states[0]
# This is the entry point for non-owned singletons
async def _track_new_launcher_id(
self,
launcher_id: bytes32,
peer: WSChiaConnection,
spend: CoinSpend | None = None,
height: uint32 | None = None,
) -> None:
if await self.wallet_state_manager.dl_store.get_launcher(launcher_id) is not None:
self.log.info(f"Spend of launcher {launcher_id} has already been processed")
return None
if spend is None or height is None:
launcher_state: CoinState = await self.get_launcher_coin_state(launcher_id, peer)
spend = await fetch_coin_spend_for_coin_state(launcher_state, peer)
assert launcher_state.spent_height is not None
height = uint32(launcher_state.spent_height)
assert spend.coin.name() == launcher_id, "coin_id should always match the launcher_id here"
full_puzhash, amount, root, inner_puzhash = launch_solution_to_singleton_info(
Program.from_serialized(spend.solution)
)
new_singleton = Coin(launcher_id, full_puzhash, amount)
singleton_record: SingletonRecord | None = await self.wallet_state_manager.dl_store.get_latest_singleton(
launcher_id
)
if singleton_record is not None:
if ( # This is an unconfirmed singleton that we know about
singleton_record.coin_id == new_singleton.name() and not singleton_record.confirmed
):
timestamp = await self.wallet_state_manager.wallet_node.get_timestamp_for_height(height)
await self.wallet_state_manager.dl_store.set_confirmed(singleton_record.coin_id, height, timestamp)
else:
# Singleton has advanced beyond generation 0 but the launcher entry
# is missing (already verified at the top of this function). This
# happens after a wallet rollback deletes the launchers row while
# the singleton_records survive. Fall through to restore it.
self.log.info(
f"Singleton {launcher_id} already tracked at generation "
f"{singleton_record.generation}, restoring launcher entry"
)
else:
timestamp = await self.wallet_state_manager.wallet_node.get_timestamp_for_height(height)
await self.wallet_state_manager.dl_store.add_singleton_record(
SingletonRecord(
coin_id=new_singleton.name(),
launcher_id=launcher_id,
root=root,
inner_puzzle_hash=inner_puzhash,
confirmed=True,
confirmed_at_height=height,
timestamp=timestamp,
lineage_proof=LineageProof(
launcher_id,
create_host_layer_puzzle(inner_puzhash, root).get_tree_hash_precalc(inner_puzhash),
amount,
),
generation=uint32(0),
)
)
await self.wallet_state_manager.dl_store.add_launcher(spend.coin, height)
await self.wallet_state_manager.add_interested_puzzle_hashes([launcher_id], [self.id()])
await self.wallet_state_manager.add_interested_coin_ids([new_singleton.name()])
new_singleton_coin_record: WalletCoinRecord | None = await self.wallet_state_manager.coin_store.get_coin_record(
new_singleton.name()
)
while new_singleton_coin_record is not None and new_singleton_coin_record.spent_block_height > 0:
# We've already synced this before, so we need to sort of force a resync
parent_spend = await fetch_coin_spend(new_singleton_coin_record.spent_block_height, new_singleton, peer)
await self.singleton_removed(parent_spend, new_singleton_coin_record.spent_block_height)
try:
additions = compute_additions(parent_spend)
new_singleton = next(coin for coin in additions if coin.amount % 2 != 0)
new_singleton_coin_record = await self.wallet_state_manager.coin_store.get_coin_record(
new_singleton.name()
)
except StopIteration:
new_singleton_coin_record = None
################
# TRANSACTIONS #
################
async def generate_new_reporter(
self,
initial_root: bytes32,
action_scope: WalletActionScope,
fee: uint64 = uint64(0),
extra_conditions: tuple[Condition, ...] = tuple(),
) -> bytes32:
"""
Creates the initial singleton, which includes spending an origin coin, the launcher, and creating a singleton
"""
coins: set[Coin] = await self.standard_wallet.select_coins(uint64(fee + 1), action_scope)
if coins is None:
raise ValueError("Not enough coins to create new data layer singleton")
launcher_parent: Coin = next(iter(coins))
launcher_coin: Coin = Coin(launcher_parent.name(), SINGLETON_LAUNCHER_PUZZLE_HASH, uint64(1))
inner_puzzle: Program = await action_scope.get_puzzle(self.wallet_state_manager)
full_puzzle: Program = create_host_fullpuz(inner_puzzle, initial_root, launcher_coin.name())
genesis_launcher_solution: Program = Program.to(
[full_puzzle.get_tree_hash(), 1, [initial_root, inner_puzzle.get_tree_hash()]]
)
announcement_message: bytes32 = genesis_launcher_solution.get_tree_hash()
announcement = AssertCoinAnnouncement(asserted_id=launcher_coin.name(), asserted_msg=announcement_message)
await self.standard_wallet.generate_signed_transaction(
amounts=[uint64(1)],
puzzle_hashes=[SINGLETON_LAUNCHER_PUZZLE_HASH],
action_scope=action_scope,
fee=fee,
origin_id=launcher_parent.name(),
coins=coins,
extra_conditions=(*extra_conditions, announcement),
)
launcher_cs: CoinSpend = make_spend(
launcher_coin,
SINGLETON_LAUNCHER_PUZZLE,
genesis_launcher_solution,
)
launcher_sb = WalletSpendBundle([launcher_cs], G2Element())
launcher_id = launcher_coin.name()
async with action_scope.use() as interface:
interface.side_effects.extra_spends.append(launcher_sb)
interface.side_effects.singleton_records.append(
SingletonRecord(
coin_id=Coin(launcher_id, full_puzzle.get_tree_hash(), uint64(1)).name(),
launcher_id=launcher_id,
root=initial_root,
inner_puzzle_hash=inner_puzzle.get_tree_hash(),
confirmed=False,
confirmed_at_height=uint32(0),
timestamp=uint64(0),
lineage_proof=LineageProof(
launcher_id,
create_host_layer_puzzle(inner_puzzle, initial_root).get_tree_hash(),
uint64(1),
),
generation=uint32(0),
)
)
await self.wallet_state_manager.add_interested_puzzle_hashes([launcher_id], [self.id()])
return launcher_id
async def create_tandem_xch_tx(
self,
fee: uint64,
announcement_to_assert: AssertAnnouncement,
action_scope: WalletActionScope,
) -> None:
await self.standard_wallet.generate_signed_transaction(
amounts=[],
puzzle_hashes=[],
action_scope=action_scope,
fee=fee,
negative_change_allowed=False,
extra_conditions=(announcement_to_assert,),
)
async def create_update_state_spend(
self,
launcher_id: bytes32,
root_hash: bytes32 | None,
action_scope: WalletActionScope,
new_puz_hash: bytes32 | None = None,
new_amount: uint64 | None = None,
fee: uint64 = uint64(0),
announce_new_state: bool = False,
extra_conditions: tuple[Condition, ...] = tuple(),
) -> None:
singleton_record, parent_lineage = await self.get_spendable_singleton_info(launcher_id)
if root_hash is None:
root_hash = singleton_record.root
inner_puzzle_derivation: (
DerivationRecord | None
) = await self.wallet_state_manager.puzzle_store.get_derivation_record_for_puzzle_hash(
singleton_record.inner_puzzle_hash
)
if inner_puzzle_derivation is None:
raise ValueError(f"DL Wallet does not have permission to update Singleton with launcher ID {launcher_id}")
# Make the child's puzzles
if new_puz_hash is None:
new_puz_hash = await action_scope.get_puzzle_hash(self.wallet_state_manager)
assert new_puz_hash is not None
next_full_puz_hash: bytes32 = create_host_fullpuz(new_puz_hash, root_hash, launcher_id).get_tree_hash_precalc(
new_puz_hash
)
# Construct the current puzzles
current_inner_puzzle: Program = self.standard_wallet.puzzle_for_pk(inner_puzzle_derivation.pubkey)
current_full_puz = create_host_fullpuz(
current_inner_puzzle,
singleton_record.root,
launcher_id,
)
assert singleton_record.lineage_proof.parent_name is not None
assert singleton_record.lineage_proof.amount is not None
current_coin = Coin(
singleton_record.lineage_proof.parent_name,
current_full_puz.get_tree_hash(),
singleton_record.lineage_proof.amount,
)
new_singleton_record = SingletonRecord(
coin_id=Coin(current_coin.name(), next_full_puz_hash, singleton_record.lineage_proof.amount).name(),
launcher_id=launcher_id,
root=root_hash,
inner_puzzle_hash=new_puz_hash,
confirmed=False,
confirmed_at_height=uint32(0),
timestamp=uint64(0),
lineage_proof=LineageProof(
singleton_record.coin_id,
new_puz_hash,
singleton_record.lineage_proof.amount,
),
generation=uint32(singleton_record.generation + 1),
)
# Optionally add an ephemeral spend to announce
if announce_new_state:
announce_only: Program = Program.to(
(
1,
[
[
51,
new_puz_hash,
singleton_record.lineage_proof.amount,
[launcher_id, root_hash, new_puz_hash],
],
[62, b"$"],
],
)
)
second_full_puz: Program = create_host_fullpuz(
announce_only,
root_hash,
launcher_id,
)
second_coin = Coin(
current_coin.name(), second_full_puz.get_tree_hash(), singleton_record.lineage_proof.amount
)
second_coin_spend = make_spend(
second_coin,
second_full_puz,
Program.to(
[
LineageProof(
current_coin.parent_coin_info,
create_host_layer_puzzle(current_inner_puzzle, singleton_record.root).get_tree_hash(),
singleton_record.lineage_proof.amount,
).to_program(),
singleton_record.lineage_proof.amount,
[[]],
]
),
)
extra_conditions += (
AssertPuzzleAnnouncement(asserted_ph=second_full_puz.get_tree_hash(), asserted_msg=b"$"),
)
second_singleton_record = SingletonRecord(
coin_id=second_coin.name(),
launcher_id=launcher_id,
root=root_hash,
inner_puzzle_hash=announce_only.get_tree_hash(),
confirmed=False,
confirmed_at_height=uint32(0),
timestamp=uint64(0),
lineage_proof=LineageProof(
second_coin.parent_coin_info,
announce_only.get_tree_hash(),
singleton_record.lineage_proof.amount,
),
generation=uint32(singleton_record.generation + 1),
)
new_singleton_record = dataclasses.replace(
new_singleton_record,
coin_id=Coin(second_coin.name(), next_full_puz_hash, singleton_record.lineage_proof.amount).name(),
lineage_proof=LineageProof(
second_coin.name(),
next_full_puz_hash,
singleton_record.lineage_proof.amount,
),
generation=uint32(second_singleton_record.generation + 1),
)
# Create the solution
primaries = [
CreateCoin(
announce_only.get_tree_hash() if announce_new_state else new_puz_hash,
singleton_record.lineage_proof.amount if new_amount is None else new_amount,
[
launcher_id,
root_hash,
announce_only.get_tree_hash() if announce_new_state else new_puz_hash,
],
)
]
if root_hash != singleton_record.root:
extra_conditions = (
*extra_conditions,
UnknownCondition(
opcode=Program.to(-24),
args=[
ACS_MU,
Program.to([[(root_hash, None), ACS_MU_PH], None]),
],
),
)
inner_sol: Program = self.standard_wallet.make_solution(
primaries=primaries,
conditions=(*extra_conditions, CreateCoinAnnouncement(b"$")) if fee > 0 else extra_conditions,
)
db_layer_sol = Program.to([inner_sol])
full_sol = Program.to(
[
parent_lineage.to_program(),
singleton_record.lineage_proof.amount,
db_layer_sol,
]
)
# Create the spend
coin_spend = make_spend(
current_coin,
current_full_puz,
full_sol,
)
spend_bundle = WalletSpendBundle([coin_spend], G2Element())
if announce_new_state:
spend_bundle = WalletSpendBundle([coin_spend, second_coin_spend], spend_bundle.aggregated_signature)
dl_tx = self.wallet_state_manager.new_outgoing_transaction(
wallet_id=self.id(),
puzzle_hash=new_puz_hash,
amount=uint64(singleton_record.lineage_proof.amount),
fee=fee,
spend_bundle=spend_bundle,
additions=spend_bundle.additions(),
removals=spend_bundle.removals(),
name=singleton_record.coin_id,
extra_conditions=extra_conditions,
)
assert dl_tx.spend_bundle is not None
if fee > 0:
await self.create_tandem_xch_tx(
fee,
AssertAnnouncement(True, asserted_origin_id=current_coin.name(), asserted_msg=b"$"),
action_scope,
)
async with action_scope.use() as interface:
interface.side_effects.transactions.append(dl_tx)
interface.side_effects.singleton_records.append(
new_singleton_record,
)
if announce_new_state:
interface.side_effects.singleton_records.append(
second_singleton_record,
)
async def generate_signed_transaction(
self,
amounts: list[uint64],
puzzle_hashes: list[bytes32],
action_scope: WalletActionScope,
fee: uint64 = uint64(0),
coins: set[Coin] | None = None,
memos: list[list[bytes]] | None = None, # ignored
extra_conditions: tuple[Condition, ...] = tuple(),
**kwargs: Unpack[GSTOptionalArgs],
) -> None:
launcher_id: bytes32 | None = kwargs.get("launcher_id", None)
new_root_hash: bytes32 | None = kwargs.get("new_root_hash", None)
announce_new_state: bool = kwargs.get("announce_new_state", False)
# Figure out the launcher ID
if coins is None or len(coins) == 0:
if launcher_id is None:
raise ValueError("Not enough info to know which DL coin to send")
elif len(coins) != 1:
raise ValueError("The wallet can only send one DL coin at a time")
else:
record = await self.wallet_state_manager.dl_store.get_singleton_record(next(iter(coins)).name())
if record is None:
raise ValueError("The specified coin is not a tracked DL")
else:
launcher_id = record.launcher_id
if len(amounts) != 1 or len(puzzle_hashes) != 1:
raise ValueError("The wallet can only send one DL coin to one place at a time")
await self.create_update_state_spend(
launcher_id,
new_root_hash,
action_scope,
puzzle_hashes[0],
amounts[0],
fee,
announce_new_state,
extra_conditions,
)
async def get_spendable_singleton_info(self, launcher_id: bytes32) -> tuple[SingletonRecord, LineageProof]:
# First, let's make sure this is a singleton that we track and that we can spend
singleton_record: SingletonRecord | None = await self.get_latest_singleton(launcher_id)
if singleton_record is None:
raise ValueError(f"Singleton with launcher ID {launcher_id} is not tracked by DL Wallet")
# Next, the singleton should be confirmed or else we shouldn't be ready to spend it
if not singleton_record.confirmed:
raise ValueError(f"Singleton with launcher ID {launcher_id} is currently pending")
# Next, let's verify we have all of the relevant coin information
if singleton_record.lineage_proof.parent_name is None or singleton_record.lineage_proof.amount is None:
raise ValueError(f"Singleton with launcher ID {launcher_id} has insufficient information to spend")
# Finally, let's get the parent record for its lineage proof
parent_singleton: SingletonRecord | None = await self.wallet_state_manager.dl_store.get_singleton_record(
singleton_record.lineage_proof.parent_name
)
parent_lineage: LineageProof
if parent_singleton is None:
if singleton_record.lineage_proof.parent_name != launcher_id:
raise ValueError(f"Have not found the parent of singleton with launcher ID {launcher_id}")
else:
launcher_coin: Coin | None = await self.wallet_state_manager.dl_store.get_launcher(launcher_id)
if launcher_coin is None:
raise ValueError(f"DL Wallet does not have launcher info for id {launcher_id}")
else:
parent_lineage = LineageProof(launcher_coin.parent_coin_info, None, uint64(launcher_coin.amount))
else:
parent_lineage = parent_singleton.lineage_proof
return singleton_record, parent_lineage
async def get_owned_singletons(self) -> list[SingletonRecord]:
launcher_ids = await self.wallet_state_manager.dl_store.get_all_launchers()
collected = []
for launcher_id in launcher_ids:
singleton_record = await self.wallet_state_manager.dl_store.get_latest_singleton(launcher_id=launcher_id)
if singleton_record is None:
# this is likely due to a race between getting the list and acquiring the extra data
continue
inner_puzzle_derivation: (
DerivationRecord | None
) = await self.wallet_state_manager.puzzle_store.get_derivation_record_for_puzzle_hash(
singleton_record.inner_puzzle_hash
)
if inner_puzzle_derivation is not None:
collected.append(singleton_record)
return collected
async def create_new_mirror(
self,
launcher_id: bytes32,
amount: uint64,
urls: list[bytes],
action_scope: WalletActionScope,
fee: uint64 = uint64(0),
extra_conditions: tuple[Condition, ...] = tuple(),
) -> None:
await self.standard_wallet.generate_signed_transaction(
amounts=[amount],
puzzle_hashes=[create_mirror_puzzle().get_tree_hash()],
action_scope=action_scope,
fee=fee,
memos=[[launcher_id, *(url for url in urls)]],
extra_conditions=extra_conditions,
)
async def delete_mirror(
self,
mirror_id: bytes32,
peer: WSChiaConnection,
action_scope: WalletActionScope,
fee: uint64 = uint64(0),
extra_conditions: tuple[Condition, ...] = tuple(),
) -> None:
mirror: Mirror = await self.get_mirror(mirror_id)
mirror_coin: Coin = (await self.wallet_state_manager.wallet_node.get_coin_state([mirror.coin_id], peer=peer))[
0
].coin
parent_coin: Coin = (
await self.wallet_state_manager.wallet_node.get_coin_state([mirror_coin.parent_coin_info], peer=peer)
)[0].coin
inner_puzzle_derivation: (
DerivationRecord | None
) = await self.wallet_state_manager.puzzle_store.get_derivation_record_for_puzzle_hash(parent_coin.puzzle_hash)
if inner_puzzle_derivation is None:
raise ValueError(f"DL Wallet does not have permission to delete mirror with ID {mirror_id}")
parent_inner_puzzle: Program = self.standard_wallet.puzzle_for_pk(inner_puzzle_derivation.pubkey)
new_puzhash: bytes32 = await action_scope.get_puzzle_hash(self.wallet_state_manager)
excess_fee: int = fee - mirror_coin.amount
inner_sol: Program = self.standard_wallet.make_solution(
primaries=[CreateCoin(new_puzhash, uint64(mirror_coin.amount - fee))] if excess_fee < 0 else [],
conditions=(*extra_conditions, CreateCoinAnnouncement(b"$")) if excess_fee > 0 else extra_conditions,
)
mirror_spend = make_spend(
mirror_coin,
create_mirror_puzzle(),
Program.to(
[
parent_coin.parent_coin_info,
parent_inner_puzzle,
parent_coin.amount,
inner_sol,
]
),
)
mirror_bundle = WalletSpendBundle([mirror_spend], G2Element())
async with action_scope.use() as interface:
interface.side_effects.transactions.append(
self.wallet_state_manager.new_outgoing_transaction(
wallet_id=self.id(),
puzzle_hash=new_puzhash,
amount=uint64(mirror_coin.amount),
fee=fee,
spend_bundle=mirror_bundle,
additions=mirror_bundle.additions(),
removals=mirror_bundle.removals(),
name=mirror_bundle.name(),
extra_conditions=extra_conditions,
)
)
if excess_fee > 0:
await self.wallet_state_manager.main_wallet.generate_signed_transaction(
[uint64(1)],
[new_puzhash],
action_scope,
fee=uint64(excess_fee),
extra_conditions=(AssertCoinAnnouncement(asserted_id=mirror_coin.name(), asserted_msg=b"$"),),
)
###########
# SYNCING #
###########
async def coin_added(self, coin: Coin, height: uint32, peer: WSChiaConnection, coin_data: object | None) -> None:
if coin.puzzle_hash == create_mirror_puzzle().get_tree_hash():
parent_state: CoinState = (
await self.wallet_state_manager.wallet_node.get_coin_state([coin.parent_coin_info], peer=peer)
)[0]
parent_spend = await fetch_coin_spend(height, parent_state.coin, peer)
assert parent_spend is not None
launcher_id, urls = get_mirror_info(parent_spend.puzzle_reveal, parent_spend.solution)
# Don't track mirrors with empty url list.
if not urls:
return
if await self.wallet_state_manager.dl_store.is_launcher_tracked(launcher_id):
ours: bool = await self.wallet_state_manager.get_wallet_for_coin(coin.parent_coin_info) is not None
await self.wallet_state_manager.dl_store.add_mirror(
Mirror(
coin.name(),
launcher_id,
uint64(coin.amount),
Mirror.decode_urls(urls),
ours,
height,
)
)
await self.wallet_state_manager.add_interested_coin_ids([coin.name()])
async def singleton_removed(self, parent_spend: CoinSpend, height: uint32) -> None:
parent_name = parent_spend.coin.name()
puzzle = parent_spend.puzzle_reveal
solution = parent_spend.solution
matched, _ = match_dl_singleton(puzzle)
if matched:
self.log.info(f"DL singleton removed: {parent_spend.coin}")
singleton_record: SingletonRecord | None = await self.wallet_state_manager.dl_store.get_singleton_record(
parent_name
)
if singleton_record is None:
self.log.warning(f"DL wallet received coin it does not have parent for. Expected parent {parent_name}.")
return
# Information we need to create the singleton record
full_puzzle_hash: bytes32
amount: uint64
root: bytes32
inner_puzzle_hash: bytes32
conditions = run_with_cost(puzzle, self.wallet_state_manager.constants.MAX_BLOCK_COST_CLVM, solution)[
1
].as_python()
found_singleton: bool = False
for condition in conditions:
if condition[0] == ConditionOpcode.CREATE_COIN and int.from_bytes(condition[2], "big") % 2 == 1:
full_puzzle_hash = bytes32(condition[1])
amount = uint64(int.from_bytes(condition[2], "big"))
try:
root = bytes32(condition[3][1])
inner_puzzle_hash = bytes32(condition[3][2])
except IndexError:
self.log.warning(
f"Parent {parent_name} with launcher {singleton_record.launcher_id} "
"did not hint its child properly"
)
return
found_singleton = True
break
if not found_singleton:
self.log.warning(f"Singleton with launcher ID {singleton_record.launcher_id} was melted")
return
new_singleton = Coin(parent_name, full_puzzle_hash, amount)
timestamp = await self.wallet_state_manager.wallet_node.get_timestamp_for_height(height)
await self.wallet_state_manager.dl_store.add_singleton_record(
SingletonRecord(
coin_id=new_singleton.name(),
launcher_id=singleton_record.launcher_id,
root=root,
inner_puzzle_hash=inner_puzzle_hash,
confirmed=True,
confirmed_at_height=height,
timestamp=timestamp,
lineage_proof=LineageProof(
parent_name,
create_host_layer_puzzle(inner_puzzle_hash, root).get_tree_hash_precalc(inner_puzzle_hash),
amount,
),
generation=uint32(singleton_record.generation + 1),
)
)
await self.wallet_state_manager.add_interested_coin_ids(
[new_singleton.name()],
)
elif parent_spend.coin.puzzle_hash == create_mirror_puzzle().get_tree_hash():
await self.wallet_state_manager.dl_store.delete_mirror(parent_name)
async def stop_tracking_singleton(self, launcher_id: bytes32) -> None:
await self.wallet_state_manager.dl_store.delete_singleton_records_by_launcher_id(launcher_id)
await self.wallet_state_manager.dl_store.delete_launcher(launcher_id)
async def track_new_launcher_id(
self,
launcher_id: bytes32,
peer: WSChiaConnection | None = None,
spend: CoinSpend | None = None,
height: uint32 | None = None,
) -> None:
if peer is None:
peer_list = self.wallet_state_manager.wallet_node.get_full_node_peers_in_order()
else:
peer_list = [peer]
peer_length = len(peer_list)
for i, peer_to_try in enumerate(peer_list):
try:
await self._track_new_launcher_id(
launcher_id,
peer_to_try,
spend,
height,
)
except LauncherCoinNotFoundError as e:
if i == peer_length - 1:
raise e # raise the error if we've tried all peers
continue # try some other peers, maybe someone has it
@classmethod
async def identify(cls, wallet_state_manager: WalletStateManager, coin_state: CoinState) -> WalletIdentifier | None:
try:
dl_wallet = await wallet_state_manager.get_dl_wallet()
except ValueError:
return None
if (
coin_state.coin.puzzle_hash == MIRROR_PUZZLE_HASH
or await dl_wallet.get_singleton_record(coin_state.coin.name()) is not None
):
return WalletIdentifier.create(dl_wallet)
return None
###########
# UTILITY #
###########
async def get_latest_singleton(self, launcher_id: bytes32, only_confirmed: bool = False) -> SingletonRecord | None:
singleton: SingletonRecord | None = await self.wallet_state_manager.dl_store.get_latest_singleton(
launcher_id, only_confirmed
)
return singleton
async def get_history(
self,
launcher_id: bytes32,
min_generation: uint32 | None = None,
max_generation: uint32 | None = None,
num_results: uint32 | None = None,
) -> list[SingletonRecord]:
history: list[SingletonRecord] = await self.wallet_state_manager.dl_store.get_all_singletons_for_launcher(
launcher_id,
min_generation,
max_generation,
num_results,
)
return history
async def get_singleton_record(self, coin_id: bytes32) -> SingletonRecord | None:
singleton: SingletonRecord | None = await self.wallet_state_manager.dl_store.get_singleton_record(coin_id)
return singleton
async def get_singletons_by_root(self, launcher_id: bytes32, root: bytes32) -> list[SingletonRecord]:
singletons: list[SingletonRecord] = await self.wallet_state_manager.dl_store.get_singletons_by_root(
launcher_id, root
)
return singletons
async def get_mirrors_for_launcher(self, launcher_id: bytes32) -> list[Mirror]:
return await self.wallet_state_manager.dl_store.get_mirrors(launcher_id)
async def get_mirror(self, coin_id: bytes32) -> Mirror:
return await self.wallet_state_manager.dl_store.get_mirror(coin_id)
##########
# WALLET #
##########
def require_derivation_paths(self) -> bool:
return True
def puzzle_hash_for_pk(self, pubkey: G1Element) -> bytes32:
puzzle: Program = self.puzzle_for_pk(pubkey)
return puzzle.get_tree_hash()
def puzzle_for_pk(self, pubkey: G1Element) -> Program:
return self.standard_wallet.puzzle_for_pk(pubkey)
async def new_peak(self, peak: BlockRecord) -> None:
pass
async def get_confirmed_balance(self, record_list: set[WalletCoinRecord] | None = None) -> uint128:
return uint128(0)
async def get_unconfirmed_balance(self, unspent_records: set[WalletCoinRecord] | None = None) -> uint128:
return uint128(0)
async def get_spendable_balance(self, unspent_records: set[WalletCoinRecord] | None = None) -> uint128:
return uint128(0)
async def get_pending_change_balance(self) -> uint64:
return uint64(0)
async def get_max_send_amount(self, records: set[WalletCoinRecord] | None = None) -> uint128:
return uint128(0)
def get_name(self) -> str:
return self.wallet_info.name
##########
# OFFERS #
##########
async def get_puzzle_info(self, launcher_id: bytes32) -> PuzzleInfo:
record = await self.get_latest_singleton(launcher_id)
if record is None:
raise ValueError(f"DL wallet does not know about launcher ID {launcher_id}")
return PuzzleInfo(
{
"type": AssetType.SINGLETON.value,
"launcher_id": "0x" + launcher_id.hex(),
"launcher_ph": "0x" + SINGLETON_LAUNCHER_PUZZLE_HASH.hex(),
"also": {
"type": AssetType.METADATA.value,
"metadata": f"(0x{record.root} . ())",
"updater_hash": "0x" + ACS_MU_PH.hex(),
},
}
)
async def get_coins_to_offer(self, launcher_id: bytes32, *args: Any, **kwargs: Any) -> set[Coin]:
record = await self.get_latest_singleton(launcher_id)
if record is None:
raise ValueError(f"DL wallet does not know about launcher ID {launcher_id}")
puzhash: bytes32 = create_host_fullpuz(
record.inner_puzzle_hash, record.root, launcher_id
).get_tree_hash_precalc(record.inner_puzzle_hash)
assert record.lineage_proof.parent_name is not None
assert record.lineage_proof.amount is not None
return {Coin(record.lineage_proof.parent_name, puzhash, record.lineage_proof.amount)}
@staticmethod
async def make_update_offer(
wallet_state_manager: Any,
offer_dict: dict[bytes32 | None, int],
driver_dict: dict[bytes32, PuzzleInfo],
solver: Solver,
action_scope: WalletActionScope,
fee: uint64 = uint64(0),
extra_conditions: tuple[Condition, ...] = tuple(),
) -> Offer:
for asset_id in offer_dict:
if asset_id is None:
raise ValueError("DataLayer update offers cannot include an XCH leg")
driver = driver_dict.get(asset_id)
if driver is None or not (
driver.check_type([AssetType.SINGLETON.value, AssetType.METADATA.value])
and driver.also()["updater_hash"] == ACS_MU_PH # type: ignore
):
raise ValueError(
f"DataLayer update offers only support DL singleton legs; "
f"asset {asset_id.hex()} is not a DataLayer singleton"
)
dl_wallet = None
for wallet in wallet_state_manager.wallets.values():
if wallet.type() == WalletType.DATA_LAYER.value:
dl_wallet = wallet
break
if dl_wallet is None:
raise ValueError("DL Wallet is not initialized")
offered_launchers: list[bytes32] = [k for k, v in offer_dict.items() if v < 0 and k is not None]
fee_left_to_pay: uint64 = fee
all_transactions: list[TransactionRecord] = []
for launcher in offered_launchers:
try:
this_solver: Solver = solver[launcher.hex()]
except KeyError:
this_solver = solver["0x" + launcher.hex()]
new_root: bytes32 = this_solver["new_root"]
new_ph: bytes32 = await action_scope.get_puzzle_hash(wallet_state_manager)
async with wallet_state_manager.new_action_scope(
action_scope.config.tx_config, push=False
) as inner_action_scope:
await dl_wallet.generate_signed_transaction(
[uint64(1)],
[new_ph],
inner_action_scope,
fee=fee_left_to_pay,
launcher_id=launcher,
new_root_hash=new_root,
announce_new_state=True,
extra_conditions=extra_conditions,
)
fee_left_to_pay = uint64(0)
extra_conditions = tuple()
async with inner_action_scope.use() as interface:
dl_spend: CoinSpend = next(
cs
for tx in interface.side_effects.transactions
for cs in tx.spend_bundle.coin_spends
if tx.spend_bundle is not None and match_dl_singleton(cs.puzzle_reveal)[0]
)
dl_solution = Program.from_serialized(dl_spend.solution)
old_graftroot: Program = dl_solution.at("rrffrf")
new_graftroot: Program = create_graftroot_offer_puz(
[bytes32(dep["launcher_id"]) for dep in this_solver["dependencies"]],
[list(v for v in dep["values_to_prove"]) for dep in this_solver["dependencies"]],
old_graftroot,
)
new_solution: Program = dl_solution.replace(rrffrf=new_graftroot, rrffrrf=Program.to([None] * 5))
new_spend: CoinSpend = dl_spend.replace(solution=new_solution.to_serialized())
async with inner_action_scope.use() as interface:
for i, tx in enumerate(interface.side_effects.transactions):
if tx.spend_bundle is not None and dl_spend in tx.spend_bundle.coin_spends:
break
else:
# No test coverage for this line because it should never be reached
raise RuntimeError("Internal logic error while constructing update offer") # pragma: no cover
new_bundle = WalletSpendBundle(
[
*(
cs
for cs in interface.side_effects.transactions[i].spend_bundle.coin_spends
if cs != dl_spend
),
new_spend,
],
G2Element(),
)
interface.side_effects.transactions[i] = dataclasses.replace(
interface.side_effects.transactions[i], spend_bundle=new_bundle, name=new_bundle.name()
)
all_transactions.extend(inner_action_scope.side_effects.transactions)
# create some dummy requested payments
requested_payments = {
k: [NotarizedPayment(bytes32.zeros, uint64(v), [], nonce=bytes32.zeros)]
for k, v in offer_dict.items()
if v > 0
}
async with action_scope.use() as interface:
interface.side_effects.transactions.extend(all_transactions)
return Offer(
requested_payments,
WalletSpendBundle.aggregate([tx.spend_bundle for tx in all_transactions if tx.spend_bundle is not None]),
driver_dict,
)
@staticmethod
async def finish_graftroot_solutions(offer: Offer, solver: Solver) -> Offer:
# Build a mapping of launcher IDs to their new innerpuz
singleton_to_innerpuzhash: dict[bytes32, bytes32] = {}
singleton_to_root: dict[bytes32, bytes32] = {}
all_parent_ids: list[bytes32] = [cs.coin.parent_coin_info for cs in offer.coin_spends()]
for spend in offer.coin_spends():
matched, curried_args = match_dl_singleton(spend.puzzle_reveal)
if matched and spend.coin.name() not in all_parent_ids:
innerpuz, root_prg, launcher_id = curried_args
singleton_struct = launcher_to_struct(bytes32(launcher_id.as_python())).get_tree_hash()
singleton_to_root[singleton_struct] = bytes32(root_prg.as_python())
singleton_to_innerpuzhash[singleton_struct] = innerpuz.get_tree_hash()
# Create all of the new solutions
new_spends: list[CoinSpend] = []
for spend in offer.coin_spends():
spend_to_add = spend
solution = Program.from_serialized(spend.solution)
if match_dl_singleton(spend.puzzle_reveal)[0]:
try:
graftroot: Program = solution.at("rrffrf")
except EvalError:
new_spends.append(spend_to_add)
continue
mod, curried_args_prg = graftroot.uncurry()
if mod == GRAFTROOT_DL_OFFERS:
_, singleton_structs, _, values_to_prove = curried_args_prg.as_iter()
all_proofs = []
roots = []
for singleton, values in zip(singleton_structs.as_iter(), values_to_prove.as_python()):
asserted_root: str | None = None
proofs_of_inclusion = []
for value in values:
for proof_of_inclusion in solver["proofs_of_inclusion"]:
root: str = proof_of_inclusion[0]
proof: tuple[int, list[bytes32]] = (proof_of_inclusion[1], proof_of_inclusion[2])
calculated_root: bytes32 = _simplify_merkle_proof(value, proof)
if (
calculated_root == bytes32.from_hexstr(root)
and calculated_root == singleton_to_root[singleton.get_tree_hash()]
):
proofs_of_inclusion.append(proof)
if asserted_root is None:
asserted_root = root
elif asserted_root != root:
raise ValueError("Malformed DL offer")
break
roots.append(asserted_root)
all_proofs.append(proofs_of_inclusion)
if sum(len(proofs) for proofs in all_proofs) < sum(1 for _ in values_to_prove.as_iter()):
raise ValueError("One or more proofs of inclusion were invalid")
new_solution: Program = solution.replace(
rrffrrf=Program.to(
[
all_proofs,
[Program.to((bytes32.from_hexstr(root), None)) for root in roots if root is not None],
[ACS_MU_PH] * len(all_proofs),
[
singleton_to_innerpuzhash[struct.get_tree_hash()]
for struct in singleton_structs.as_iter()
],
solution.at("rrffrrfrrrrf"),
]
)
)
spend_to_add = spend.replace(solution=new_solution.to_serialized())
new_spends.append(spend_to_add)
return Offer({}, WalletSpendBundle(new_spends, offer.aggregated_signature()), offer.driver_dict)
@staticmethod
async def get_offer_summary(offer: Offer) -> DataLayerSummary:
singleton_summaries = []
for spend in offer.coin_spends():
solution = Program.from_serialized(spend.solution)
matched, curried_args = match_dl_singleton(spend.puzzle_reveal)
if matched:
try:
graftroot: Program = solution.at("rrffrf")
except EvalError:
continue
mod, graftroot_curried_args = graftroot.uncurry()
if mod == GRAFTROOT_DL_OFFERS:
child_spend: CoinSpend = next(
cs for cs in offer.coin_spends() if cs.coin.parent_coin_info == spend.coin.name()
)
_, child_curried_args = match_dl_singleton(child_spend.puzzle_reveal)
_, singleton_structs, _, values_to_prove = graftroot_curried_args.as_iter()
dependencies = []
for struct, values in zip(singleton_structs.as_iter(), values_to_prove.as_iter()):
dependencies.append(
SingletonDependencies(
launcher_id=bytes32(struct.at("rf").as_atom()),
values_to_prove=[value.as_atom() for value in values.as_iter()],
)
)
singleton_summary = SingletonSummary(
launcher_id=bytes32(list(curried_args)[2].as_atom()),
new_root=bytes32(list(child_curried_args)[1].as_atom()),
dependencies=dependencies,
)
singleton_summaries.append(singleton_summary)
return DataLayerSummary(offered=singleton_summaries)
async def select_coins(
self,
amount: uint64,
action_scope: WalletActionScope,
) -> set[Coin]:
raise RuntimeError("DataLayerWallet does not support select_coins()")
async def match_hinted_coin(self, coin: Coin, hint: bytes32) -> bool:
return coin.amount % 2 == 1 and await self.wallet_state_manager.dl_store.get_launcher(hint) is not None
def verify_offer(
maker: tuple[StoreProofs, ...],
taker: tuple[OfferStore, ...],
summary: DataLayerSummary,
) -> None:
# TODO: consistency in error messages
# TODO: custom exceptions
# TODO: show data in errors?
# TODO: collect and report all failures
# TODO: review for case coverage (and test those cases)
if len({store_proof.store_id for store_proof in maker}) != len(maker):
raise OfferIntegrityError("maker: repeated store id")
for store_proof in maker:
proofs: list[ProofOfInclusion] = []
for reference_proof in store_proof.proofs:
proof = ProofOfInclusion(
node_hash=reference_proof.node_hash,
layers=[
ProofOfInclusionLayer(
other_hash_side=layer.other_hash_side,
other_hash=layer.other_hash,
combined_hash=layer.combined_hash,
)
for layer in reference_proof.layers
],
)
proofs.append(proof)
if leaf_hash(key=reference_proof.key, value=reference_proof.value) != proof.node_hash:
raise OfferIntegrityError("maker: node hash does not match key and value")
if not proof.valid():
raise OfferIntegrityError("maker: invalid proof of inclusion found")
# TODO: verify each kv hash to the proof's node hash
roots = {proof.root_hash() for proof in proofs}
if len(roots) > 1:
raise OfferIntegrityError("maker: multiple roots referenced for a single store id")
if len(roots) < 1:
raise OfferIntegrityError("maker: no roots referenced for store id")
# TODO: what about validating duplicate entries are consistent?
maker_from_offer = {offered.launcher_id: offered.new_root for offered in summary.offered}
maker_from_reference = {
# verified above that there is at least one proof and all combined hashes match
store_proof.store_id: store_proof.proofs[0].root()
for store_proof in maker
}
if maker_from_offer != maker_from_reference:
raise OfferIntegrityError("maker: offered stores and their roots do not match the reference data")
taker_from_offer = {
dependency.launcher_id: [bytes32(value) for value in dependency.values_to_prove]
for offered in summary.offered
for dependency in offered.dependencies
}
taker_from_reference = {
store.store_id: [leaf_hash(key=inclusion.key, value=inclusion.value) for inclusion in store.inclusions]
for store in taker
}
if taker_from_offer != taker_from_reference:
raise OfferIntegrityError("taker: reference and offer inclusions do not match")