forked from LBRYCommunity/lbry-sdk
added txo_list command
This commit is contained in:
parent
1731046011
commit
3ff9e99416
7 changed files with 280 additions and 98 deletions
|
@ -21,9 +21,10 @@ from prometheus_client import generate_latest as prom_generate_latest
|
||||||
from google.protobuf.message import DecodeError
|
from google.protobuf.message import DecodeError
|
||||||
from lbry.wallet import (
|
from lbry.wallet import (
|
||||||
Wallet, ENCRYPT_ON_DISK, SingleKey, HierarchicalDeterministic,
|
Wallet, ENCRYPT_ON_DISK, SingleKey, HierarchicalDeterministic,
|
||||||
Transaction, Output, Input, Account
|
Transaction, Output, Input, Account, database
|
||||||
)
|
)
|
||||||
from lbry.wallet.dewies import dewies_to_lbc, lbc_to_dewies, dict_values_to_lbc
|
from lbry.wallet.dewies import dewies_to_lbc, lbc_to_dewies, dict_values_to_lbc
|
||||||
|
from lbry.wallet.constants import TXO_TYPES, CLAIM_TYPE_NAMES
|
||||||
|
|
||||||
from lbry import utils
|
from lbry import utils
|
||||||
from lbry.conf import Config, Setting, NOT_SET
|
from lbry.conf import Config, Setting, NOT_SET
|
||||||
|
@ -2167,19 +2168,20 @@ class Daemon(metaclass=JSONRPCServerType):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@requires(WALLET_COMPONENT)
|
@requires(WALLET_COMPONENT)
|
||||||
def jsonrpc_claim_list(
|
def jsonrpc_claim_list(self, claim_type=None, **kwargs):
|
||||||
self, claim_type=None, account_id=None, wallet_id=None, page=None, page_size=None, resolve=False):
|
|
||||||
"""
|
"""
|
||||||
List my stream and channel claims.
|
List my stream and channel claims.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
claim_list [--claim_type=<claim_type>...]
|
claim_list [--claim_type=<claim_type>...] [--claim_id=<claim_id>...] [--name=<name>...]
|
||||||
[--account_id=<account_id>] [--wallet_id=<wallet_id>]
|
[--account_id=<account_id>] [--wallet_id=<wallet_id>]
|
||||||
[--page=<page>] [--page_size=<page_size>]
|
[--page=<page>] [--page_size=<page_size>]
|
||||||
[--resolve]
|
[--resolve]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--claim_type=<claim_type> : (str) claim type: channel, stream, repost, collection
|
--claim_type=<claim_type> : (str or list) claim type: channel, stream, repost, collection
|
||||||
|
--claim_id=<claim_id> : (str or list) claim id
|
||||||
|
--name=<name> : (str or list) claim name
|
||||||
--account_id=<account_id> : (str) id of the account to query
|
--account_id=<account_id> : (str) id of the account to query
|
||||||
--wallet_id=<wallet_id> : (str) restrict results to specific wallet
|
--wallet_id=<wallet_id> : (str) restrict results to specific wallet
|
||||||
--page=<page> : (int) page to return during paginating
|
--page=<page> : (int) page to return during paginating
|
||||||
|
@ -2188,15 +2190,9 @@ class Daemon(metaclass=JSONRPCServerType):
|
||||||
|
|
||||||
Returns: {Paginated[Output]}
|
Returns: {Paginated[Output]}
|
||||||
"""
|
"""
|
||||||
wallet = self.wallet_manager.get_wallet_or_default(wallet_id)
|
kwargs['type'] = claim_type or CLAIM_TYPE_NAMES
|
||||||
if account_id:
|
kwargs['unspent'] = True
|
||||||
account = wallet.get_account_or_error(account_id)
|
return self.jsonrpc_txo_list(**kwargs)
|
||||||
claims = account.get_claims
|
|
||||||
claim_count = account.get_claim_count
|
|
||||||
else:
|
|
||||||
claims = partial(self.ledger.get_claims, wallet=wallet, accounts=wallet.accounts)
|
|
||||||
claim_count = partial(self.ledger.get_claim_count, wallet=wallet, accounts=wallet.accounts)
|
|
||||||
return paginate_rows(claims, claim_count, page, page_size, claim_type=claim_type, resolve=resolve)
|
|
||||||
|
|
||||||
@requires(WALLET_COMPONENT)
|
@requires(WALLET_COMPONENT)
|
||||||
async def jsonrpc_claim_search(self, **kwargs):
|
async def jsonrpc_claim_search(self, **kwargs):
|
||||||
|
@ -2699,15 +2695,18 @@ class Daemon(metaclass=JSONRPCServerType):
|
||||||
return tx
|
return tx
|
||||||
|
|
||||||
@requires(WALLET_COMPONENT)
|
@requires(WALLET_COMPONENT)
|
||||||
def jsonrpc_channel_list(self, account_id=None, wallet_id=None, page=None, page_size=None, resolve=False):
|
def jsonrpc_channel_list(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
List my channel claims.
|
List my channel claims.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
channel_list [<account_id> | --account_id=<account_id>] [--wallet_id=<wallet_id>]
|
channel_list [<account_id> | --account_id=<account_id>] [--wallet_id=<wallet_id>]
|
||||||
|
[--name=<name>...] [--claim_id=<claim_id>...]
|
||||||
[--page=<page>] [--page_size=<page_size>] [--resolve]
|
[--page=<page>] [--page_size=<page_size>] [--resolve]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
--name=<name> : (str or list) channel name
|
||||||
|
--claim_id=<claim_id> : (str or list) channel id
|
||||||
--account_id=<account_id> : (str) id of the account to use
|
--account_id=<account_id> : (str) id of the account to use
|
||||||
--wallet_id=<wallet_id> : (str) restrict results to specific wallet
|
--wallet_id=<wallet_id> : (str) restrict results to specific wallet
|
||||||
--page=<page> : (int) page to return during paginating
|
--page=<page> : (int) page to return during paginating
|
||||||
|
@ -2716,15 +2715,9 @@ class Daemon(metaclass=JSONRPCServerType):
|
||||||
|
|
||||||
Returns: {Paginated[Output]}
|
Returns: {Paginated[Output]}
|
||||||
"""
|
"""
|
||||||
wallet = self.wallet_manager.get_wallet_or_default(wallet_id)
|
kwargs['type'] = 'channel'
|
||||||
if account_id:
|
kwargs['unspent'] = True
|
||||||
account = wallet.get_account_or_error(account_id)
|
return self.jsonrpc_txo_list(*args, **kwargs)
|
||||||
channels = account.get_channels
|
|
||||||
channel_count = account.get_channel_count
|
|
||||||
else:
|
|
||||||
channels = partial(self.ledger.get_channels, wallet=wallet, accounts=wallet.accounts)
|
|
||||||
channel_count = partial(self.ledger.get_channel_count, wallet=wallet, accounts=wallet.accounts)
|
|
||||||
return paginate_rows(channels, channel_count, page, page_size, resolve=resolve)
|
|
||||||
|
|
||||||
@requires(WALLET_COMPONENT)
|
@requires(WALLET_COMPONENT)
|
||||||
async def jsonrpc_channel_export(self, channel_id=None, channel_name=None, account_id=None, wallet_id=None):
|
async def jsonrpc_channel_export(self, channel_id=None, channel_name=None, account_id=None, wallet_id=None):
|
||||||
|
@ -3441,15 +3434,18 @@ class Daemon(metaclass=JSONRPCServerType):
|
||||||
return tx
|
return tx
|
||||||
|
|
||||||
@requires(WALLET_COMPONENT)
|
@requires(WALLET_COMPONENT)
|
||||||
def jsonrpc_stream_list(self, account_id=None, wallet_id=None, page=None, page_size=None, resolve=False):
|
def jsonrpc_stream_list(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
List my stream claims.
|
List my stream claims.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
stream_list [<account_id> | --account_id=<account_id>] [--wallet_id=<wallet_id>]
|
stream_list [<account_id> | --account_id=<account_id>] [--wallet_id=<wallet_id>]
|
||||||
|
[--name=<name>...] [--claim_id=<claim_id>...]
|
||||||
[--page=<page>] [--page_size=<page_size>] [--resolve]
|
[--page=<page>] [--page_size=<page_size>] [--resolve]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
--name=<name> : (str or list) stream name
|
||||||
|
--claim_id=<claim_id> : (str or list) stream id
|
||||||
--account_id=<account_id> : (str) id of the account to query
|
--account_id=<account_id> : (str) id of the account to query
|
||||||
--wallet_id=<wallet_id> : (str) restrict results to specific wallet
|
--wallet_id=<wallet_id> : (str) restrict results to specific wallet
|
||||||
--page=<page> : (int) page to return during paginating
|
--page=<page> : (int) page to return during paginating
|
||||||
|
@ -3458,15 +3454,9 @@ class Daemon(metaclass=JSONRPCServerType):
|
||||||
|
|
||||||
Returns: {Paginated[Output]}
|
Returns: {Paginated[Output]}
|
||||||
"""
|
"""
|
||||||
wallet = self.wallet_manager.get_wallet_or_default(wallet_id)
|
kwargs['type'] = 'stream'
|
||||||
if account_id:
|
kwargs['unspent'] = True
|
||||||
account = wallet.get_account_or_error(account_id)
|
return self.jsonrpc_txo_list(*args, **kwargs)
|
||||||
streams = account.get_streams
|
|
||||||
stream_count = account.get_stream_count
|
|
||||||
else:
|
|
||||||
streams = partial(self.ledger.get_streams, wallet=wallet, accounts=wallet.accounts)
|
|
||||||
stream_count = partial(self.ledger.get_stream_count, wallet=wallet, accounts=wallet.accounts)
|
|
||||||
return paginate_rows(streams, stream_count, page, page_size, resolve=resolve)
|
|
||||||
|
|
||||||
@requires(WALLET_COMPONENT, EXCHANGE_RATE_MANAGER_COMPONENT, BLOB_COMPONENT,
|
@requires(WALLET_COMPONENT, EXCHANGE_RATE_MANAGER_COMPONENT, BLOB_COMPONENT,
|
||||||
DHT_COMPONENT, DATABASE_COMPONENT)
|
DHT_COMPONENT, DATABASE_COMPONENT)
|
||||||
|
@ -3912,15 +3902,18 @@ class Daemon(metaclass=JSONRPCServerType):
|
||||||
return tx
|
return tx
|
||||||
|
|
||||||
@requires(WALLET_COMPONENT)
|
@requires(WALLET_COMPONENT)
|
||||||
def jsonrpc_support_list(self, account_id=None, wallet_id=None, page=None, page_size=None):
|
def jsonrpc_support_list(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
List supports and tips in my control.
|
List supports and tips in my control.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
support_list [<account_id> | --account_id=<account_id>] [--wallet_id=<wallet_id>]
|
support_list [<account_id> | --account_id=<account_id>] [--wallet_id=<wallet_id>]
|
||||||
|
[--name=<name>...] [--claim_id=<claim_id>...]
|
||||||
[--page=<page>] [--page_size=<page_size>]
|
[--page=<page>] [--page_size=<page_size>]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
--name=<name> : (str or list) claim name
|
||||||
|
--claim_id=<claim_id> : (str or list) claim id
|
||||||
--account_id=<account_id> : (str) id of the account to query
|
--account_id=<account_id> : (str) id of the account to query
|
||||||
--wallet_id=<wallet_id> : (str) restrict results to specific wallet
|
--wallet_id=<wallet_id> : (str) restrict results to specific wallet
|
||||||
--page=<page> : (int) page to return during paginating
|
--page=<page> : (int) page to return during paginating
|
||||||
|
@ -3928,15 +3921,9 @@ class Daemon(metaclass=JSONRPCServerType):
|
||||||
|
|
||||||
Returns: {Paginated[Output]}
|
Returns: {Paginated[Output]}
|
||||||
"""
|
"""
|
||||||
wallet = self.wallet_manager.get_wallet_or_default(wallet_id)
|
kwargs['type'] = 'support'
|
||||||
if account_id:
|
kwargs['unspent'] = True
|
||||||
account = wallet.get_account_or_error(account_id)
|
return self.jsonrpc_txo_list(*args, **kwargs)
|
||||||
supports = account.get_supports
|
|
||||||
support_count = account.get_support_count
|
|
||||||
else:
|
|
||||||
supports = partial(self.ledger.get_supports, wallet=wallet, accounts=wallet.accounts)
|
|
||||||
support_count = partial(self.ledger.get_support_count, wallet=wallet, accounts=wallet.accounts)
|
|
||||||
return paginate_rows(supports, support_count, page, page_size)
|
|
||||||
|
|
||||||
@requires(WALLET_COMPONENT)
|
@requires(WALLET_COMPONENT)
|
||||||
async def jsonrpc_support_abandon(
|
async def jsonrpc_support_abandon(
|
||||||
|
@ -4103,12 +4090,60 @@ class Daemon(metaclass=JSONRPCServerType):
|
||||||
"""
|
"""
|
||||||
return self.wallet_manager.get_transaction(txid)
|
return self.wallet_manager.get_transaction(txid)
|
||||||
|
|
||||||
|
TXO_DOC = """
|
||||||
|
List transaction outputs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@requires(WALLET_COMPONENT)
|
||||||
|
def jsonrpc_txo_list(
|
||||||
|
self, account_id=None, type=None, txid=None, # pylint: disable=redefined-builtin
|
||||||
|
claim_id=None, name=None, unspent=False,
|
||||||
|
wallet_id=None, page=None, page_size=None, resolve=False):
|
||||||
|
"""
|
||||||
|
List my transaction outputs.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
txo_list [--account_id=<account_id>] [--type=<type>...] [--txid=<txid>...]
|
||||||
|
[--claim_id=<claim_id>...] [--name=<name>...] [--unspent]
|
||||||
|
[--wallet_id=<wallet_id>]
|
||||||
|
[--page=<page>] [--page_size=<page_size>]
|
||||||
|
[--resolve]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--type=<type> : (str or list) claim type: stream, channel, support,
|
||||||
|
purchase, collection, repost, other
|
||||||
|
--txid=<txid> : (str or list) transaction id of outputs
|
||||||
|
--unspent : (bool) hide spent outputs, show only unspent ones
|
||||||
|
--claim_id=<claim_id> : (str or list) claim id
|
||||||
|
--name=<name> : (str or list) claim name
|
||||||
|
--account_id=<account_id> : (str) id of the account to query
|
||||||
|
--wallet_id=<wallet_id> : (str) restrict results to specific wallet
|
||||||
|
--page=<page> : (int) page to return during paginating
|
||||||
|
--page_size=<page_size> : (int) number of items on page during pagination
|
||||||
|
--resolve : (bool) resolves each claim to provide additional metadata
|
||||||
|
|
||||||
|
Returns: {Paginated[Output]}
|
||||||
|
"""
|
||||||
|
wallet = self.wallet_manager.get_wallet_or_default(wallet_id)
|
||||||
|
if account_id:
|
||||||
|
account = wallet.get_account_or_error(account_id)
|
||||||
|
claims = account.get_txos
|
||||||
|
claim_count = account.get_txo_count
|
||||||
|
else:
|
||||||
|
claims = partial(self.ledger.get_txos, wallet=wallet, accounts=wallet.accounts)
|
||||||
|
claim_count = partial(self.ledger.get_txo_count, wallet=wallet, accounts=wallet.accounts)
|
||||||
|
constraints = {'resolve': resolve, 'unspent': unspent}
|
||||||
|
database.constrain_single_or_list(constraints, 'txo_type', type, lambda x: TXO_TYPES[x])
|
||||||
|
database.constrain_single_or_list(constraints, 'claim_id', claim_id)
|
||||||
|
database.constrain_single_or_list(constraints, 'claim_name', name)
|
||||||
|
return paginate_rows(claims, claim_count, page, page_size, **constraints)
|
||||||
|
|
||||||
UTXO_DOC = """
|
UTXO_DOC = """
|
||||||
Unspent transaction management.
|
Unspent transaction management.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@requires(WALLET_COMPONENT)
|
@requires(WALLET_COMPONENT)
|
||||||
def jsonrpc_utxo_list(self, account_id=None, wallet_id=None, page=None, page_size=None):
|
def jsonrpc_utxo_list(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
List unspent transaction outputs
|
List unspent transaction outputs
|
||||||
|
|
||||||
|
@ -4124,15 +4159,9 @@ class Daemon(metaclass=JSONRPCServerType):
|
||||||
|
|
||||||
Returns: {Paginated[Output]}
|
Returns: {Paginated[Output]}
|
||||||
"""
|
"""
|
||||||
wallet = self.wallet_manager.get_wallet_or_default(wallet_id)
|
kwargs['type'] = ['other', 'purchase']
|
||||||
if account_id:
|
kwargs['unspent'] = True
|
||||||
account = wallet.get_account_or_error(account_id)
|
return self.jsonrpc_txo_list(*args, **kwargs)
|
||||||
utxos = account.get_utxos
|
|
||||||
utxo_count = account.get_utxo_count
|
|
||||||
else:
|
|
||||||
utxos = partial(self.ledger.get_utxos, wallet=wallet, accounts=wallet.accounts)
|
|
||||||
utxo_count = partial(self.ledger.get_utxo_count, wallet=wallet, accounts=wallet.accounts)
|
|
||||||
return paginate_rows(utxos, utxo_count, page, page_size)
|
|
||||||
|
|
||||||
@requires(WALLET_COMPONENT)
|
@requires(WALLET_COMPONENT)
|
||||||
async def jsonrpc_utxo_release(self, account_id=None, wallet_id=None):
|
async def jsonrpc_utxo_release(self, account_id=None, wallet_id=None):
|
||||||
|
|
|
@ -564,6 +564,9 @@ class CommandTestCase(IntegrationTestCase):
|
||||||
async def file_list(self, *args, **kwargs):
|
async def file_list(self, *args, **kwargs):
|
||||||
return (await self.out(self.daemon.jsonrpc_file_list(*args, **kwargs)))['items']
|
return (await self.out(self.daemon.jsonrpc_file_list(*args, **kwargs)))['items']
|
||||||
|
|
||||||
|
async def txo_list(self, *args, **kwargs):
|
||||||
|
return (await self.out(self.daemon.jsonrpc_txo_list(*args, **kwargs)))['items']
|
||||||
|
|
||||||
async def claim_list(self, *args, **kwargs):
|
async def claim_list(self, *args, **kwargs):
|
||||||
return (await self.out(self.daemon.jsonrpc_claim_list(*args, **kwargs)))['items']
|
return (await self.out(self.daemon.jsonrpc_claim_list(*args, **kwargs)))['items']
|
||||||
|
|
||||||
|
@ -573,6 +576,9 @@ class CommandTestCase(IntegrationTestCase):
|
||||||
async def channel_list(self, *args, **kwargs):
|
async def channel_list(self, *args, **kwargs):
|
||||||
return (await self.out(self.daemon.jsonrpc_channel_list(*args, **kwargs)))['items']
|
return (await self.out(self.daemon.jsonrpc_channel_list(*args, **kwargs)))['items']
|
||||||
|
|
||||||
|
async def transaction_list(self, *args, **kwargs):
|
||||||
|
return (await self.out(self.daemon.jsonrpc_transaction_list(*args, **kwargs)))['items']
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_claim_id(tx):
|
def get_claim_id(tx):
|
||||||
return tx['outputs'][0]['claim_id']
|
return tx['outputs'][0]['claim_id']
|
||||||
|
|
|
@ -467,6 +467,12 @@ class Account:
|
||||||
'max_receiving_gap': receiving_gap,
|
'max_receiving_gap': receiving_gap,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_txos(self, **constraints):
|
||||||
|
return self.ledger.get_txos(wallet=self.wallet, accounts=[self], **constraints)
|
||||||
|
|
||||||
|
def get_txo_count(self, **constraints):
|
||||||
|
return self.ledger.get_txo_count(wallet=self.wallet, accounts=[self], **constraints)
|
||||||
|
|
||||||
def get_utxos(self, **constraints):
|
def get_utxos(self, **constraints):
|
||||||
return self.ledger.get_utxos(wallet=self.wallet, accounts=[self], **constraints)
|
return self.ledger.get_utxos(wallet=self.wallet, accounts=[self], **constraints)
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ COIN = 100*CENT
|
||||||
TIMEOUT = 30.0
|
TIMEOUT = 30.0
|
||||||
|
|
||||||
TXO_TYPES = {
|
TXO_TYPES = {
|
||||||
|
"other": 0,
|
||||||
"stream": 1,
|
"stream": 1,
|
||||||
"channel": 2,
|
"channel": 2,
|
||||||
"support": 3,
|
"support": 3,
|
||||||
|
@ -14,9 +15,13 @@ TXO_TYPES = {
|
||||||
"repost": 6,
|
"repost": 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
CLAIM_TYPES = [
|
CLAIM_TYPE_NAMES = [
|
||||||
TXO_TYPES['stream'],
|
'stream',
|
||||||
TXO_TYPES['channel'],
|
'channel',
|
||||||
TXO_TYPES['collection'],
|
'collection',
|
||||||
TXO_TYPES['repost'],
|
'repost',
|
||||||
|
]
|
||||||
|
|
||||||
|
CLAIM_TYPES = [
|
||||||
|
TXO_TYPES[name] for name in CLAIM_TYPE_NAMES
|
||||||
]
|
]
|
||||||
|
|
|
@ -107,7 +107,9 @@ def constraints_to_sql(constraints, joiner=' AND ', prepend_key=''):
|
||||||
if not key:
|
if not key:
|
||||||
sql.append(constraint)
|
sql.append(constraint)
|
||||||
continue
|
continue
|
||||||
if key.startswith('$'):
|
if key.startswith('$$'):
|
||||||
|
col, key = col[2:], key[1:]
|
||||||
|
elif key.startswith('$'):
|
||||||
values[key] = constraint
|
values[key] = constraint
|
||||||
continue
|
continue
|
||||||
if key.endswith('__not'):
|
if key.endswith('__not'):
|
||||||
|
@ -221,6 +223,19 @@ def rows_to_dict(rows, fields):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def constrain_single_or_list(constraints, column, value, convert=lambda x: x):
|
||||||
|
if value is not None:
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = [convert(v) for v in value]
|
||||||
|
if len(value) == 1:
|
||||||
|
constraints[column] = value[0]
|
||||||
|
elif len(value) > 1:
|
||||||
|
constraints[f"{column}__in"] = value
|
||||||
|
else:
|
||||||
|
constraints[column] = convert(value)
|
||||||
|
return constraints
|
||||||
|
|
||||||
|
|
||||||
class SQLiteMixin:
|
class SQLiteMixin:
|
||||||
|
|
||||||
SCHEMA_VERSION: Optional[str] = None
|
SCHEMA_VERSION: Optional[str] = None
|
||||||
|
@ -350,6 +365,7 @@ class Database(SQLiteMixin):
|
||||||
create index if not exists txo_txid_idx on txo (txid);
|
create index if not exists txo_txid_idx on txo (txid);
|
||||||
create index if not exists txo_address_idx on txo (address);
|
create index if not exists txo_address_idx on txo (address);
|
||||||
create index if not exists txo_claim_id_idx on txo (claim_id);
|
create index if not exists txo_claim_id_idx on txo (claim_id);
|
||||||
|
create index if not exists txo_claim_name_idx on txo (claim_name);
|
||||||
create index if not exists txo_txo_type_idx on txo (txo_type);
|
create index if not exists txo_txo_type_idx on txo (txo_type);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -474,16 +490,15 @@ class Database(SQLiteMixin):
|
||||||
async def select_transactions(self, cols, accounts=None, **constraints):
|
async def select_transactions(self, cols, accounts=None, **constraints):
|
||||||
if not {'txid', 'txid__in'}.intersection(constraints):
|
if not {'txid', 'txid__in'}.intersection(constraints):
|
||||||
assert accounts, "'accounts' argument required when no 'txid' constraint is present"
|
assert accounts, "'accounts' argument required when no 'txid' constraint is present"
|
||||||
constraints.update({
|
where, values = constraints_to_sql({
|
||||||
f'$account{i}': a.public_key.address for i, a in enumerate(accounts)
|
'$$account_address.account__in': [a.public_key.address for a in accounts]
|
||||||
})
|
})
|
||||||
account_values = ', '.join([f':$account{i}' for i in range(len(accounts))])
|
|
||||||
where = f" WHERE account_address.account IN ({account_values})"
|
|
||||||
constraints['txid__in'] = f"""
|
constraints['txid__in'] = f"""
|
||||||
SELECT txo.txid FROM txo JOIN account_address USING (address) {where}
|
SELECT txo.txid FROM txo JOIN account_address USING (address) WHERE {where}
|
||||||
UNION
|
UNION
|
||||||
SELECT txi.txid FROM txi JOIN account_address USING (address) {where}
|
SELECT txi.txid FROM txi JOIN account_address USING (address) WHERE {where}
|
||||||
"""
|
"""
|
||||||
|
constraints.update(values)
|
||||||
return await self.db.execute_fetchall(
|
return await self.db.execute_fetchall(
|
||||||
*query(f"SELECT {cols} FROM tx", **constraints)
|
*query(f"SELECT {cols} FROM tx", **constraints)
|
||||||
)
|
)
|
||||||
|
@ -568,7 +583,14 @@ class Database(SQLiteMixin):
|
||||||
sql += " JOIN account_address USING (address)"
|
sql += " JOIN account_address USING (address)"
|
||||||
return await self.db.execute_fetchall(*query(sql, **constraints))
|
return await self.db.execute_fetchall(*query(sql, **constraints))
|
||||||
|
|
||||||
async def get_txos(self, wallet=None, no_tx=False, **constraints):
|
@staticmethod
|
||||||
|
def constrain_unspent(constraints):
|
||||||
|
constraints['is_reserved'] = False
|
||||||
|
constraints['txoid__not_in'] = "SELECT txoid FROM txi"
|
||||||
|
|
||||||
|
async def get_txos(self, wallet=None, no_tx=False, unspent=False, **constraints):
|
||||||
|
if unspent:
|
||||||
|
self.constrain_unspent(constraints)
|
||||||
my_accounts = {a.public_key.address for a in wallet.accounts} if wallet else set()
|
my_accounts = {a.public_key.address for a in wallet.accounts} if wallet else set()
|
||||||
if 'order_by' not in constraints:
|
if 'order_by' not in constraints:
|
||||||
constraints['order_by'] = [
|
constraints['order_by'] = [
|
||||||
|
@ -637,33 +659,28 @@ class Database(SQLiteMixin):
|
||||||
|
|
||||||
return txos
|
return txos
|
||||||
|
|
||||||
async def get_txo_count(self, **constraints):
|
async def get_txo_count(self, unspent=False, **constraints):
|
||||||
constraints.pop('resolve', None)
|
constraints.pop('resolve', None)
|
||||||
constraints.pop('wallet', None)
|
constraints.pop('wallet', None)
|
||||||
constraints.pop('offset', None)
|
constraints.pop('offset', None)
|
||||||
constraints.pop('limit', None)
|
constraints.pop('limit', None)
|
||||||
constraints.pop('order_by', None)
|
constraints.pop('order_by', None)
|
||||||
|
if unspent:
|
||||||
|
self.constrain_unspent(constraints)
|
||||||
count = await self.select_txos('count(*)', **constraints)
|
count = await self.select_txos('count(*)', **constraints)
|
||||||
return count[0][0]
|
return count[0][0]
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def constrain_utxo(constraints):
|
|
||||||
constraints['is_reserved'] = False
|
|
||||||
constraints['txoid__not_in'] = "SELECT txoid FROM txi"
|
|
||||||
|
|
||||||
def get_utxos(self, **constraints):
|
def get_utxos(self, **constraints):
|
||||||
self.constrain_utxo(constraints)
|
return self.get_txos(unspent=True, **constraints)
|
||||||
return self.get_txos(**constraints)
|
|
||||||
|
|
||||||
def get_utxo_count(self, **constraints):
|
def get_utxo_count(self, **constraints):
|
||||||
self.constrain_utxo(constraints)
|
return self.get_txo_count(unspent=True, **constraints)
|
||||||
return self.get_txo_count(**constraints)
|
|
||||||
|
|
||||||
async def get_balance(self, wallet=None, accounts=None, **constraints):
|
async def get_balance(self, wallet=None, accounts=None, **constraints):
|
||||||
assert wallet or accounts, \
|
assert wallet or accounts, \
|
||||||
"'wallet' or 'accounts' constraints required to calculate balance"
|
"'wallet' or 'accounts' constraints required to calculate balance"
|
||||||
constraints['accounts'] = accounts or wallet.accounts
|
constraints['accounts'] = accounts or wallet.accounts
|
||||||
self.constrain_utxo(constraints)
|
self.constrain_unspent(constraints)
|
||||||
balance = await self.select_txos('SUM(amount)', **constraints)
|
balance = await self.select_txos('SUM(amount)', **constraints)
|
||||||
return balance[0][0] or 0
|
return balance[0][0] or 0
|
||||||
|
|
||||||
|
@ -746,11 +763,13 @@ class Database(SQLiteMixin):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def constrain_claims(constraints):
|
def constrain_claims(constraints):
|
||||||
|
if {'txo_type', 'txo_type__in'}.intersection(constraints):
|
||||||
|
return
|
||||||
claim_types = constraints.pop('claim_type', None)
|
claim_types = constraints.pop('claim_type', None)
|
||||||
if isinstance(claim_types, str) and claim_types:
|
if claim_types:
|
||||||
claim_types = [claim_types]
|
constrain_single_or_list(
|
||||||
if isinstance(claim_types, list) and claim_types:
|
constraints, 'txo_type', claim_types, lambda x: TXO_TYPES[x]
|
||||||
constraints['txo_type__in'] = [TXO_TYPES[ct] for ct in claim_types]
|
)
|
||||||
else:
|
else:
|
||||||
constraints['txo_type__in'] = CLAIM_TYPES
|
constraints['txo_type__in'] = CLAIM_TYPES
|
||||||
|
|
||||||
|
|
|
@ -262,6 +262,15 @@ class Ledger(metaclass=LedgerRegistry):
|
||||||
self.constraint_spending_utxos(constraints)
|
self.constraint_spending_utxos(constraints)
|
||||||
return self.db.get_utxo_count(**constraints)
|
return self.db.get_utxo_count(**constraints)
|
||||||
|
|
||||||
|
async def get_txos(self, resolve=False, **constraints):
|
||||||
|
txos = await self.db.get_txos(**constraints)
|
||||||
|
if resolve:
|
||||||
|
return await self._resolve_for_local_results(constraints.get('accounts', []), txos)
|
||||||
|
return txos
|
||||||
|
|
||||||
|
def get_txo_count(self, **constraints):
|
||||||
|
return self.db.get_txo_count(**constraints)
|
||||||
|
|
||||||
def get_transactions(self, **constraints):
|
def get_transactions(self, **constraints):
|
||||||
return self.db.get_transactions(**constraints)
|
return self.db.get_transactions(**constraints)
|
||||||
|
|
||||||
|
@ -735,9 +744,11 @@ class Ledger(metaclass=LedgerRegistry):
|
||||||
|
|
||||||
async def _resolve_for_local_results(self, accounts, txos):
|
async def _resolve_for_local_results(self, accounts, txos):
|
||||||
results = []
|
results = []
|
||||||
response = await self.resolve(accounts, [txo.permanent_url for txo in txos])
|
response = await self.resolve(
|
||||||
|
accounts, [txo.permanent_url for txo in txos if txo.can_decode_claim]
|
||||||
|
)
|
||||||
for txo in txos:
|
for txo in txos:
|
||||||
resolved = response[txo.permanent_url]
|
resolved = response.get(txo.permanent_url) if txo.can_decode_claim else None
|
||||||
if isinstance(resolved, Output):
|
if isinstance(resolved, Output):
|
||||||
resolved.update_annotations(txo)
|
resolved.update_annotations(txo)
|
||||||
results.append(resolved)
|
results.append(resolved)
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import asyncio
|
|
||||||
import os.path
|
import os.path
|
||||||
import tempfile
|
import tempfile
|
||||||
import logging
|
import logging
|
||||||
|
@ -399,12 +398,96 @@ class ClaimSearchCommand(ClaimTestCase):
|
||||||
await self.assertFindsClaims([], text='cloud')
|
await self.assertFindsClaims([], text='cloud')
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionCommands(ClaimTestCase):
|
||||||
|
|
||||||
|
async def test_transaction_list(self):
|
||||||
|
channel_id = self.get_claim_id(await self.channel_create())
|
||||||
|
await self.channel_update(channel_id, bid='0.5')
|
||||||
|
await self.channel_abandon(claim_id=channel_id)
|
||||||
|
stream_id = self.get_claim_id(await self.stream_create())
|
||||||
|
await self.stream_update(stream_id, bid='0.5')
|
||||||
|
await self.stream_abandon(claim_id=stream_id)
|
||||||
|
|
||||||
|
r = await self.transaction_list()
|
||||||
|
self.assertEqual(7, len(r))
|
||||||
|
self.assertEqual(stream_id, r[0]['abandon_info'][0]['claim_id'])
|
||||||
|
self.assertEqual(stream_id, r[1]['update_info'][0]['claim_id'])
|
||||||
|
self.assertEqual(stream_id, r[2]['claim_info'][0]['claim_id'])
|
||||||
|
self.assertEqual(channel_id, r[3]['abandon_info'][0]['claim_id'])
|
||||||
|
self.assertEqual(channel_id, r[4]['update_info'][0]['claim_id'])
|
||||||
|
self.assertEqual(channel_id, r[5]['claim_info'][0]['claim_id'])
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionOutputCommands(ClaimTestCase):
|
||||||
|
|
||||||
|
async def test_txo_list_filtering(self):
|
||||||
|
channel_id = self.get_claim_id(await self.channel_create())
|
||||||
|
await self.channel_update(channel_id, bid='0.5')
|
||||||
|
stream_id = self.get_claim_id(await self.stream_create())
|
||||||
|
await self.stream_update(stream_id, bid='0.5')
|
||||||
|
|
||||||
|
# type filtering
|
||||||
|
r = await self.txo_list(type='channel')
|
||||||
|
self.assertEqual(2, len(r))
|
||||||
|
self.assertEqual('channel', r[0]['value_type'])
|
||||||
|
self.assertFalse(r[0]['is_spent'])
|
||||||
|
self.assertEqual('channel', r[1]['value_type'])
|
||||||
|
self.assertTrue(r[1]['is_spent'])
|
||||||
|
|
||||||
|
r = await self.txo_list(type='stream')
|
||||||
|
self.assertEqual(2, len(r))
|
||||||
|
self.assertEqual('stream', r[0]['value_type'])
|
||||||
|
self.assertFalse(r[0]['is_spent'])
|
||||||
|
self.assertEqual('stream', r[1]['value_type'])
|
||||||
|
self.assertTrue(r[1]['is_spent'])
|
||||||
|
|
||||||
|
r = await self.txo_list(type=['stream', 'channel'])
|
||||||
|
self.assertEqual(4, len(r))
|
||||||
|
self.assertEqual({'stream', 'channel'}, {c['value_type'] for c in r})
|
||||||
|
|
||||||
|
# claim_id filtering
|
||||||
|
r = await self.txo_list(claim_id=stream_id)
|
||||||
|
self.assertEqual(2, len(r))
|
||||||
|
self.assertEqual({stream_id}, {c['claim_id'] for c in r})
|
||||||
|
|
||||||
|
r = await self.txo_list(claim_id=[stream_id, channel_id])
|
||||||
|
self.assertEqual(4, len(r))
|
||||||
|
self.assertEqual({stream_id, channel_id}, {c['claim_id'] for c in r})
|
||||||
|
stream_name, _, channel_name, _ = (c['name'] for c in r)
|
||||||
|
|
||||||
|
r = await self.txo_list(claim_id=['beef'])
|
||||||
|
self.assertEqual(0, len(r))
|
||||||
|
|
||||||
|
# claim_name filtering
|
||||||
|
r = await self.txo_list(name=stream_name)
|
||||||
|
self.assertEqual(2, len(r))
|
||||||
|
self.assertEqual({stream_id}, {c['claim_id'] for c in r})
|
||||||
|
|
||||||
|
r = await self.txo_list(name=[stream_name, channel_name])
|
||||||
|
self.assertEqual(4, len(r))
|
||||||
|
self.assertEqual({stream_id, channel_id}, {c['claim_id'] for c in r})
|
||||||
|
|
||||||
|
r = await self.txo_list(name=['beef'])
|
||||||
|
self.assertEqual(0, len(r))
|
||||||
|
|
||||||
|
r = await self.txo_list()
|
||||||
|
self.assertEqual(9, len(r))
|
||||||
|
await self.stream_abandon(claim_id=stream_id)
|
||||||
|
r = await self.txo_list()
|
||||||
|
self.assertEqual(10, len(r))
|
||||||
|
r = await self.txo_list(claim_id=stream_id)
|
||||||
|
self.assertEqual(2, len(r))
|
||||||
|
self.assertTrue(r[0]['is_spent'])
|
||||||
|
self.assertTrue(r[1]['is_spent'])
|
||||||
|
|
||||||
|
|
||||||
class ClaimCommands(ClaimTestCase):
|
class ClaimCommands(ClaimTestCase):
|
||||||
|
|
||||||
async def test_claim_list_type_filtering(self):
|
async def test_claim_list_filtering(self):
|
||||||
await self.channel_create()
|
channel_id = self.get_claim_id(await self.channel_create())
|
||||||
await self.stream_create()
|
stream_id = self.get_claim_id(await self.stream_create())
|
||||||
|
|
||||||
|
# type filtering
|
||||||
r = await self.claim_list(claim_type='channel')
|
r = await self.claim_list(claim_type='channel')
|
||||||
self.assertEqual(1, len(r))
|
self.assertEqual(1, len(r))
|
||||||
self.assertEqual('channel', r[0]['value_type'])
|
self.assertEqual('channel', r[0]['value_type'])
|
||||||
|
@ -417,6 +500,31 @@ class ClaimCommands(ClaimTestCase):
|
||||||
self.assertEqual(2, len(r))
|
self.assertEqual(2, len(r))
|
||||||
self.assertEqual({'stream', 'channel'}, {c['value_type'] for c in r})
|
self.assertEqual({'stream', 'channel'}, {c['value_type'] for c in r})
|
||||||
|
|
||||||
|
# claim_id filtering
|
||||||
|
r = await self.claim_list(claim_id=stream_id)
|
||||||
|
self.assertEqual(1, len(r))
|
||||||
|
self.assertEqual({stream_id}, {c['claim_id'] for c in r})
|
||||||
|
|
||||||
|
r = await self.claim_list(claim_id=[stream_id, channel_id])
|
||||||
|
self.assertEqual(2, len(r))
|
||||||
|
self.assertEqual({stream_id, channel_id}, {c['claim_id'] for c in r})
|
||||||
|
stream_name, channel_name = (c['name'] for c in r)
|
||||||
|
|
||||||
|
r = await self.claim_list(claim_id=['beef'])
|
||||||
|
self.assertEqual(0, len(r))
|
||||||
|
|
||||||
|
# claim_name filtering
|
||||||
|
r = await self.claim_list(name=stream_name)
|
||||||
|
self.assertEqual(1, len(r))
|
||||||
|
self.assertEqual({stream_id}, {c['claim_id'] for c in r})
|
||||||
|
|
||||||
|
r = await self.claim_list(name=[stream_name, channel_name])
|
||||||
|
self.assertEqual(2, len(r))
|
||||||
|
self.assertEqual({stream_id, channel_id}, {c['claim_id'] for c in r})
|
||||||
|
|
||||||
|
r = await self.claim_list(name=['beef'])
|
||||||
|
self.assertEqual(0, len(r))
|
||||||
|
|
||||||
async def test_claim_stream_channel_list_with_resolve(self):
|
async def test_claim_stream_channel_list_with_resolve(self):
|
||||||
self.assertListEqual([], await self.claim_list(resolve=True))
|
self.assertListEqual([], await self.claim_list(resolve=True))
|
||||||
|
|
||||||
|
@ -1364,7 +1472,7 @@ class StreamCommands(ClaimTestCase):
|
||||||
|
|
||||||
tx = await self.stream_create(bid='2.5') # creates new claim
|
tx = await self.stream_create(bid='2.5') # creates new claim
|
||||||
claim_id = self.get_claim_id(tx)
|
claim_id = self.get_claim_id(tx)
|
||||||
txs = (await self.out(self.daemon.jsonrpc_transaction_list()))['items']
|
txs = await self.transaction_list()
|
||||||
self.assertEqual(len(txs[0]['claim_info']), 1)
|
self.assertEqual(len(txs[0]['claim_info']), 1)
|
||||||
self.assertEqual(txs[0]['confirmations'], 1)
|
self.assertEqual(txs[0]['confirmations'], 1)
|
||||||
self.assertEqual(txs[0]['claim_info'][0]['balance_delta'], '-2.5')
|
self.assertEqual(txs[0]['claim_info'][0]['balance_delta'], '-2.5')
|
||||||
|
@ -1379,7 +1487,7 @@ class StreamCommands(ClaimTestCase):
|
||||||
self.assertItemCount(await self.daemon.jsonrpc_file_list(), 0)
|
self.assertItemCount(await self.daemon.jsonrpc_file_list(), 0)
|
||||||
|
|
||||||
await self.stream_update(claim_id, bid='1.0') # updates previous claim
|
await self.stream_update(claim_id, bid='1.0') # updates previous claim
|
||||||
txs = (await self.out(self.daemon.jsonrpc_transaction_list()))['items']
|
txs = await self.transaction_list()
|
||||||
self.assertEqual(len(txs[0]['update_info']), 1)
|
self.assertEqual(len(txs[0]['update_info']), 1)
|
||||||
self.assertEqual(txs[0]['update_info'][0]['balance_delta'], '1.5')
|
self.assertEqual(txs[0]['update_info'][0]['balance_delta'], '1.5')
|
||||||
self.assertEqual(txs[0]['update_info'][0]['claim_id'], claim_id)
|
self.assertEqual(txs[0]['update_info'][0]['claim_id'], claim_id)
|
||||||
|
@ -1390,7 +1498,7 @@ class StreamCommands(ClaimTestCase):
|
||||||
await self.assertBalance(self.account, '8.9796765')
|
await self.assertBalance(self.account, '8.9796765')
|
||||||
|
|
||||||
await self.stream_abandon(claim_id)
|
await self.stream_abandon(claim_id)
|
||||||
txs = (await self.out(self.daemon.jsonrpc_transaction_list()))['items']
|
txs = await self.transaction_list()
|
||||||
self.assertEqual(len(txs[0]['abandon_info']), 1)
|
self.assertEqual(len(txs[0]['abandon_info']), 1)
|
||||||
self.assertEqual(txs[0]['abandon_info'][0]['balance_delta'], '1.0')
|
self.assertEqual(txs[0]['abandon_info'][0]['balance_delta'], '1.0')
|
||||||
self.assertEqual(txs[0]['abandon_info'][0]['claim_id'], claim_id)
|
self.assertEqual(txs[0]['abandon_info'][0]['claim_id'], claim_id)
|
||||||
|
@ -1494,7 +1602,7 @@ class SupportCommands(CommandTestCase):
|
||||||
await self.assertBalance(account2, '3.9998585')
|
await self.assertBalance(account2, '3.9998585')
|
||||||
|
|
||||||
# verify that the incoming tip is marked correctly as is_tip=True in account1
|
# verify that the incoming tip is marked correctly as is_tip=True in account1
|
||||||
txs = (await self.out(self.daemon.jsonrpc_transaction_list(self.account.id)))['items']
|
txs = await self.transaction_list(account_id=self.account.id)
|
||||||
self.assertEqual(len(txs[0]['support_info']), 1)
|
self.assertEqual(len(txs[0]['support_info']), 1)
|
||||||
self.assertEqual(txs[0]['support_info'][0]['balance_delta'], '1.0')
|
self.assertEqual(txs[0]['support_info'][0]['balance_delta'], '1.0')
|
||||||
self.assertEqual(txs[0]['support_info'][0]['claim_id'], claim_id)
|
self.assertEqual(txs[0]['support_info'][0]['claim_id'], claim_id)
|
||||||
|
@ -1504,9 +1612,7 @@ class SupportCommands(CommandTestCase):
|
||||||
self.assertEqual(txs[0]['fee'], '0.0')
|
self.assertEqual(txs[0]['fee'], '0.0')
|
||||||
|
|
||||||
# verify that the outgoing tip is marked correctly as is_tip=True in account2
|
# verify that the outgoing tip is marked correctly as is_tip=True in account2
|
||||||
txs2 = (await self.out(
|
txs2 = await self.transaction_list(wallet_id='wallet2', account_id=account2.id)
|
||||||
self.daemon.jsonrpc_transaction_list(wallet_id='wallet2', account_id=account2.id)
|
|
||||||
))['items']
|
|
||||||
self.assertEqual(len(txs2[0]['support_info']), 1)
|
self.assertEqual(len(txs2[0]['support_info']), 1)
|
||||||
self.assertEqual(txs2[0]['support_info'][0]['balance_delta'], '-1.0')
|
self.assertEqual(txs2[0]['support_info'][0]['balance_delta'], '-1.0')
|
||||||
self.assertEqual(txs2[0]['support_info'][0]['claim_id'], claim_id)
|
self.assertEqual(txs2[0]['support_info'][0]['claim_id'], claim_id)
|
||||||
|
@ -1527,7 +1633,7 @@ class SupportCommands(CommandTestCase):
|
||||||
await self.assertBalance(account2, '1.999717')
|
await self.assertBalance(account2, '1.999717')
|
||||||
|
|
||||||
# verify that the outgoing support is marked correctly as is_tip=False in account2
|
# verify that the outgoing support is marked correctly as is_tip=False in account2
|
||||||
txs2 = (await self.out(self.daemon.jsonrpc_transaction_list(wallet_id='wallet2')))['items']
|
txs2 = await self.transaction_list(wallet_id='wallet2')
|
||||||
self.assertEqual(len(txs2[0]['support_info']), 1)
|
self.assertEqual(len(txs2[0]['support_info']), 1)
|
||||||
self.assertEqual(txs2[0]['support_info'][0]['balance_delta'], '-2.0')
|
self.assertEqual(txs2[0]['support_info'][0]['balance_delta'], '-2.0')
|
||||||
self.assertEqual(txs2[0]['support_info'][0]['claim_id'], claim_id)
|
self.assertEqual(txs2[0]['support_info'][0]['claim_id'], claim_id)
|
||||||
|
@ -1539,7 +1645,7 @@ class SupportCommands(CommandTestCase):
|
||||||
# abandoning the tip increases balance and shows tip as spent
|
# abandoning the tip increases balance and shows tip as spent
|
||||||
await self.support_abandon(claim_id)
|
await self.support_abandon(claim_id)
|
||||||
await self.assertBalance(self.account, '4.979662')
|
await self.assertBalance(self.account, '4.979662')
|
||||||
txs = (await self.out(self.daemon.jsonrpc_transaction_list(self.account.id)))['items']
|
txs = await self.transaction_list(account_id=self.account.id)
|
||||||
self.assertEqual(len(txs[0]['abandon_info']), 1)
|
self.assertEqual(len(txs[0]['abandon_info']), 1)
|
||||||
self.assertEqual(len(txs[1]['support_info']), 1)
|
self.assertEqual(len(txs[1]['support_info']), 1)
|
||||||
self.assertTrue(txs[1]['support_info'][0]['is_tip'])
|
self.assertTrue(txs[1]['support_info'][0]['is_tip'])
|
||||||
|
|
Loading…
Reference in a new issue