dao-treasury 0.0.22__cp310-cp310-macosx_11_0_arm64.whl → 0.0.69__cp310-cp310-macosx_11_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. dao_treasury/.grafana/provisioning/dashboards/breakdowns/Expenses.json +551 -0
  2. dao_treasury/.grafana/provisioning/dashboards/breakdowns/Revenue.json +551 -0
  3. dao_treasury/.grafana/provisioning/dashboards/dashboards.yaml +7 -7
  4. dao_treasury/.grafana/provisioning/dashboards/streams/LlamaPay.json +220 -0
  5. dao_treasury/.grafana/provisioning/dashboards/summary/Monthly.json +18 -23
  6. dao_treasury/.grafana/provisioning/dashboards/transactions/Treasury Transactions.json +181 -29
  7. dao_treasury/.grafana/provisioning/dashboards/treasury/Cashflow (Including Unsorted).json +808 -0
  8. dao_treasury/.grafana/provisioning/dashboards/treasury/Cashflow.json +602 -0
  9. dao_treasury/.grafana/provisioning/dashboards/treasury/Current Treasury Assets.json +1009 -0
  10. dao_treasury/.grafana/provisioning/dashboards/treasury/Historical Treasury Balances.json +2989 -0
  11. dao_treasury/.grafana/provisioning/dashboards/treasury/Operating Cashflow.json +478 -0
  12. dao_treasury/.grafana/provisioning/datasources/datasources.yaml +17 -0
  13. dao_treasury/ENVIRONMENT_VARIABLES.py +20 -0
  14. dao_treasury/__init__.py +20 -0
  15. dao_treasury/_docker.cpython-310-darwin.so +0 -0
  16. dao_treasury/_docker.py +67 -38
  17. dao_treasury/_nicknames.cpython-310-darwin.so +0 -0
  18. dao_treasury/_nicknames.py +24 -2
  19. dao_treasury/_wallet.cpython-310-darwin.so +0 -0
  20. dao_treasury/_wallet.py +157 -16
  21. dao_treasury/constants.cpython-310-darwin.so +0 -0
  22. dao_treasury/constants.py +39 -0
  23. dao_treasury/db.py +384 -45
  24. dao_treasury/docker-compose.yaml +6 -5
  25. dao_treasury/main.py +86 -17
  26. dao_treasury/sorting/__init__.cpython-310-darwin.so +0 -0
  27. dao_treasury/sorting/__init__.py +171 -42
  28. dao_treasury/sorting/_matchers.cpython-310-darwin.so +0 -0
  29. dao_treasury/sorting/_rules.cpython-310-darwin.so +0 -0
  30. dao_treasury/sorting/_rules.py +1 -3
  31. dao_treasury/sorting/factory.cpython-310-darwin.so +0 -0
  32. dao_treasury/sorting/factory.py +2 -6
  33. dao_treasury/sorting/rule.cpython-310-darwin.so +0 -0
  34. dao_treasury/sorting/rule.py +13 -10
  35. dao_treasury/sorting/rules/__init__.cpython-310-darwin.so +0 -0
  36. dao_treasury/sorting/rules/__init__.py +1 -0
  37. dao_treasury/sorting/rules/ignore/__init__.cpython-310-darwin.so +0 -0
  38. dao_treasury/sorting/rules/ignore/__init__.py +1 -0
  39. dao_treasury/sorting/rules/ignore/llamapay.cpython-310-darwin.so +0 -0
  40. dao_treasury/sorting/rules/ignore/llamapay.py +20 -0
  41. dao_treasury/streams/__init__.cpython-310-darwin.so +0 -0
  42. dao_treasury/streams/__init__.py +0 -0
  43. dao_treasury/streams/llamapay.cpython-310-darwin.so +0 -0
  44. dao_treasury/streams/llamapay.py +388 -0
  45. dao_treasury/treasury.py +75 -28
  46. dao_treasury/types.cpython-310-darwin.so +0 -0
  47. dao_treasury-0.0.69.dist-info/METADATA +120 -0
  48. dao_treasury-0.0.69.dist-info/RECORD +54 -0
  49. dao_treasury-0.0.69.dist-info/top_level.txt +2 -0
  50. dao_treasury__mypyc.cpython-310-darwin.so +0 -0
  51. 52b51d40e96d4333695d__mypyc.cpython-310-darwin.so +0 -0
  52. dao_treasury/.grafana/provisioning/datasources/sqlite.yaml +0 -10
  53. dao_treasury-0.0.22.dist-info/METADATA +0 -63
  54. dao_treasury-0.0.22.dist-info/RECORD +0 -31
  55. dao_treasury-0.0.22.dist-info/top_level.txt +0 -2
  56. {dao_treasury-0.0.22.dist-info → dao_treasury-0.0.69.dist-info}/WHEEL +0 -0
dao_treasury/db.py CHANGED
@@ -3,11 +3,13 @@
3
3
  Database models and utilities for DAO treasury reporting.
4
4
 
5
5
  This module defines Pony ORM entities for:
6
+
6
7
  - Blockchain networks (:class:`Chain`)
7
8
  - On-chain addresses (:class:`Address`)
8
9
  - ERC-20 tokens and native coin placeholder (:class:`Token`)
9
10
  - Hierarchical transaction grouping (:class:`TxGroup`)
10
11
  - Treasury transaction records (:class:`TreasuryTx`)
12
+ - Streams and StreamedFunds for streaming payments
11
13
 
12
14
  It also provides helper functions for inserting ledger entries,
13
15
  resolving integrity conflicts, caching transaction receipts,
@@ -16,28 +18,44 @@ and creating SQL views for reporting.
16
18
 
17
19
  import typing
18
20
  from asyncio import Semaphore
21
+ from collections import OrderedDict
19
22
  from decimal import Decimal, InvalidOperation
20
23
  from functools import lru_cache
21
24
  from logging import getLogger
22
25
  from os import path
23
26
  from pathlib import Path
24
- from typing import TYPE_CHECKING, Final, Union, final
27
+ from typing import (
28
+ TYPE_CHECKING,
29
+ Any,
30
+ Coroutine,
31
+ Dict,
32
+ Final,
33
+ Literal,
34
+ Tuple,
35
+ Union,
36
+ final,
37
+ overload,
38
+ )
39
+ from datetime import date, datetime, time, timezone
25
40
 
41
+ import eth_portfolio
26
42
  from a_sync import AsyncThreadPoolExecutor
27
43
  from brownie import chain
28
44
  from brownie.convert.datatypes import HexString
29
45
  from brownie.exceptions import EventLookupError
30
46
  from brownie.network.event import EventDict, _EventItem
31
47
  from brownie.network.transaction import TransactionReceipt
32
- from eth_typing import ChecksumAddress, HexAddress
33
48
  from eth_portfolio.structs import (
34
49
  InternalTransfer,
35
50
  LedgerEntry,
36
51
  TokenTransfer,
37
52
  Transaction,
38
53
  )
54
+ from eth_retry import auto_retry
55
+ from eth_typing import ChecksumAddress, HexAddress, HexStr
39
56
  from pony.orm import (
40
57
  Database,
58
+ InterfaceError,
41
59
  Optional,
42
60
  PrimaryKey,
43
61
  Required,
@@ -50,13 +68,17 @@ from pony.orm import (
50
68
  select,
51
69
  )
52
70
  from y import EEE_ADDRESS, Contract, Network, convert, get_block_timestamp_async
53
- from y.constants import CHAINID
71
+ from y._db.decorators import retry_locked
54
72
  from y.contracts import _get_code
55
73
  from y.exceptions import ContractNotVerified
56
74
 
75
+ from dao_treasury.constants import CHAINID
57
76
  from dao_treasury.types import TxGroupDbid, TxGroupName
58
77
 
59
78
 
79
+ EventItem = _EventItem[_EventItem[OrderedDict[str, Any]]]
80
+
81
+
60
82
  SQLITE_DIR = Path(path.expanduser("~")) / ".dao-treasury"
61
83
  """Path to the directory in the user's home where the DAO treasury SQLite database is stored."""
62
84
 
@@ -64,8 +86,11 @@ SQLITE_DIR.mkdir(parents=True, exist_ok=True)
64
86
 
65
87
 
66
88
  _INSERT_THREAD = AsyncThreadPoolExecutor(1)
89
+ _SORT_THREAD = AsyncThreadPoolExecutor(1)
90
+ _EVENTS_THREADS = AsyncThreadPoolExecutor(16)
67
91
  _SORT_SEMAPHORE = Semaphore(50)
68
92
 
93
+ _UTC = timezone.utc
69
94
 
70
95
  db = Database()
71
96
 
@@ -114,13 +139,13 @@ class Chain(DbEntity):
114
139
  chainid = Required(int, unique=True)
115
140
  """Numeric chain ID matching the connected RPC via :data:`~y.constants.CHAINID`."""
116
141
 
117
- addresses = Set("Address", reverse="chain")
142
+ addresses = Set("Address", reverse="chain", lazy=True)
118
143
  """Relationship to address records on this chain."""
119
144
 
120
- tokens = Set("Token", reverse="chain")
145
+ tokens = Set("Token", reverse="chain", lazy=True)
121
146
  """Relationship to token records on this chain."""
122
147
 
123
- treasury_txs = Set("TreasuryTx")
148
+ treasury_txs = Set("TreasuryTx", lazy=True)
124
149
  """Relationship to treasury transactions on this chain."""
125
150
 
126
151
  @staticmethod
@@ -179,7 +204,7 @@ class Address(DbEntity):
179
204
  address_id = PrimaryKey(int, auto=True)
180
205
  """Auto-incremented primary key for the addresses table."""
181
206
 
182
- chain = Required(Chain, reverse="addresses")
207
+ chain = Required(Chain, reverse="addresses", lazy=True)
183
208
  """Reference to the chain on which this address resides."""
184
209
 
185
210
  address = Required(str, index=True)
@@ -188,7 +213,7 @@ class Address(DbEntity):
188
213
  nickname = Optional(str)
189
214
  """Optional human-readable label (e.g., contract name or token name)."""
190
215
 
191
- is_contract = Required(bool, index=True)
216
+ is_contract = Required(bool, index=True, lazy=True)
192
217
  """Flag indicating whether the address is a smart contract."""
193
218
 
194
219
  composite_key(address, chain)
@@ -199,21 +224,22 @@ class Address(DbEntity):
199
224
  treasury_tx_from: Set["TreasuryTx"]
200
225
  treasury_tx_to: Set["TreasuryTx"]
201
226
 
202
- token = Optional("Token", index=True)
227
+ token = Optional("Token", index=True, lazy=True)
203
228
  """Optional back-reference to a Token if this address is one."""
204
- # partners_tx = Set('PartnerHarvestEvent', reverse='wrapper')
229
+ # partners_tx = Set('PartnerHarvestEvent', reverse='wrapper', lazy=True)
205
230
 
206
- treasury_tx_from = Set("TreasuryTx", reverse="from_address")
231
+ treasury_tx_from = Set("TreasuryTx", reverse="from_address", lazy=True)
207
232
  """Inverse relation for transactions sent from this address."""
208
233
 
209
- treasury_tx_to = Set("TreasuryTx", reverse="to_address")
234
+ treasury_tx_to = Set("TreasuryTx", reverse="to_address", lazy=True)
210
235
  """Inverse relation for transactions sent to this address."""
211
- # streams_from = Set("Stream", reverse="from_address")
212
- # streams_to = Set("Stream", reverse="to_address")
213
- # streams = Set("Stream", reverse="contract")
214
- # vesting_escrows = Set("VestingEscrow", reverse="address")
215
- # vests_received = Set("VestingEscrow", reverse="recipient")
216
- # vests_funded = Set("VestingEscrow", reverse="funder")
236
+
237
+ streams_from = Set("Stream", reverse="from_address", lazy=True)
238
+ streams_to = Set("Stream", reverse="to_address", lazy=True)
239
+ streams = Set("Stream", reverse="contract", lazy=True)
240
+ # vesting_escrows = Set("VestingEscrow", reverse="address", lazy=True)
241
+ # vests_received = Set("VestingEscrow", reverse="recipient", lazy=True)
242
+ # vests_funded = Set("VestingEscrow", reverse="funder", lazy=True)
217
243
 
218
244
  def __eq__(self, other: Union["Address", ChecksumAddress, "Token"]) -> bool: # type: ignore [override]
219
245
  if isinstance(other, str):
@@ -224,6 +250,14 @@ class Address(DbEntity):
224
250
 
225
251
  __hash__ = DbEntity.__hash__
226
252
 
253
+ @property
254
+ def contract(self) -> Contract:
255
+ return Contract(self.address)
256
+
257
+ @property
258
+ def contract_coro(self) -> Coroutine[Any, Any, Contract]:
259
+ return Contract.coroutine(self.address)
260
+
227
261
  @staticmethod
228
262
  @lru_cache(maxsize=None)
229
263
  def get_dbid(address: HexAddress) -> int:
@@ -284,7 +318,6 @@ class Address(DbEntity):
284
318
  )
285
319
 
286
320
  commit()
287
-
288
321
  return entity # type: ignore [no-any-return]
289
322
 
290
323
  @staticmethod
@@ -307,6 +340,12 @@ class Address(DbEntity):
307
340
  commit()
308
341
  logger.info("%s nickname set to %s", entity.address, nickname)
309
342
 
343
+ @staticmethod
344
+ def set_nicknames(nicknames: Dict[HexAddress, str]) -> None:
345
+ with db_session:
346
+ for address, nickname in nicknames.items():
347
+ Address.set_nickname(address, nickname)
348
+
310
349
 
311
350
  UNI_V3_POS: Final = {
312
351
  Network.Mainnet: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
@@ -351,29 +390,30 @@ class Token(DbEntity):
351
390
  token_id = PrimaryKey(int, auto=True)
352
391
  """Auto-incremented primary key for the tokens table."""
353
392
 
354
- chain = Required(Chain, index=True)
393
+ chain = Required(Chain, index=True, lazy=True)
355
394
  """Foreign key linking to :class:`~dao_treasury.db.Chain`."""
356
395
 
357
- symbol = Required(str, index=True)
396
+ symbol = Required(str, index=True, lazy=True)
358
397
  """Short ticker symbol for the token."""
359
398
 
360
- name = Required(str)
399
+ name = Required(str, lazy=True)
361
400
  """Full human-readable name of the token."""
362
401
 
363
- decimals = Required(int)
402
+ decimals = Required(int, lazy=True)
364
403
  """Number of decimals used for value scaling."""
365
404
 
366
405
  if TYPE_CHECKING:
367
406
  treasury_tx: Set["TreasuryTx"]
368
407
 
369
- treasury_tx = Set("TreasuryTx", reverse="token")
408
+ treasury_tx = Set("TreasuryTx", reverse="token", lazy=True)
370
409
  """Inverse relation for treasury transactions involving this token."""
371
- # partner_harvest_event = Set('PartnerHarvestEvent', reverse="vault")
410
+ # partner_harvest_event = Set('PartnerHarvestEvent', reverse="vault", lazy=True)
372
411
 
373
412
  address = Required(Address, column="address_id")
374
413
  """Foreign key to the address record for this token contract."""
375
- # streams = Set('Stream', reverse="token")
376
- # vesting_escrows = Set("VestingEscrow", reverse="token")
414
+
415
+ streams = Set("Stream", reverse="token", lazy=True)
416
+ # vesting_escrows = Set("VestingEscrow", reverse="token", lazy=True)
377
417
 
378
418
  def __eq__(self, other: Union["Token", Address, ChecksumAddress]) -> bool: # type: ignore [override]
379
419
  if isinstance(other, str):
@@ -388,6 +428,10 @@ class Token(DbEntity):
388
428
  def contract(self) -> Contract:
389
429
  return Contract(self.address.address)
390
430
 
431
+ @property
432
+ def contract_coro(self) -> Coroutine[Any, Any, Contract]:
433
+ return Contract.coroutine(self.address.address)
434
+
391
435
  @property
392
436
  def scale(self) -> int:
393
437
  """Base for division according to `decimals`, e.g., `10**decimals`.
@@ -516,7 +560,7 @@ class TxGroup(DbEntity):
516
560
  name = Required(str)
517
561
  """Name of the grouping category, e.g., 'Revenue', 'Expenses'."""
518
562
 
519
- treasury_tx = Set("TreasuryTx", reverse="txgroup")
563
+ treasury_tx = Set("TreasuryTx", reverse="txgroup", lazy=True)
520
564
  """Inverse relation for treasury transactions assigned to this group."""
521
565
 
522
566
  parent_txgroup = Optional("TxGroup", reverse="child_txgroups")
@@ -524,11 +568,13 @@ class TxGroup(DbEntity):
524
568
 
525
569
  composite_key(name, parent_txgroup)
526
570
 
527
- child_txgroups = Set("TxGroup", reverse="parent_txgroup")
571
+ child_txgroups = Set("TxGroup", reverse="parent_txgroup", lazy=True)
528
572
  """Set of nested child groups."""
529
- # TODO: implement these
530
- # streams = Set("Stream", reverse="txgroup")
531
- # vesting_escrows = Set("VestingEscrow", reverse="txgroup")
573
+
574
+ streams = Set("Stream", reverse="txgroup", lazy=True)
575
+
576
+ # TODO: implement this
577
+ # vesting_escrows = Set("VestingEscrow", reverse="txgroup", lazy=True)
532
578
 
533
579
  @property
534
580
  def fullname(self) -> str:
@@ -604,7 +650,7 @@ class TxGroup(DbEntity):
604
650
  return txgroup # type: ignore [no-any-return]
605
651
 
606
652
 
607
- @lru_cache(100)
653
+ @lru_cache(500)
608
654
  def get_transaction(txhash: str) -> TransactionReceipt:
609
655
  """Fetch and cache a transaction receipt from the connected chain.
610
656
 
@@ -700,6 +746,10 @@ class TreasuryTx(DbEntity):
700
746
  """Human-readable label for the sender address."""
701
747
  return self.from_address.nickname or self.from_address.address # type: ignore [union-attr]
702
748
 
749
+ @property
750
+ def token_address(self) -> ChecksumAddress:
751
+ return self.token.address.address
752
+
703
753
  @property
704
754
  def symbol(self) -> str:
705
755
  """Ticker symbol for the transferred token."""
@@ -710,7 +760,23 @@ class TreasuryTx(DbEntity):
710
760
  """Decoded event logs for this transaction."""
711
761
  return self._transaction.events
712
762
 
713
- def get_events(self, event_name: str) -> _EventItem:
763
+ async def events_async(self) -> EventDict:
764
+ """Asynchronously fetch decoded event logs for this transaction."""
765
+ tx = self._transaction
766
+ events = tx._events
767
+ if events is None:
768
+ events = await _EVENTS_THREADS.run(getattr, tx, "events")
769
+ return events
770
+
771
+ @overload
772
+ def get_events(
773
+ self, event_name: str, sync: Literal[False]
774
+ ) -> Coroutine[Any, Any, EventItem]: ...
775
+ @overload
776
+ def get_events(self, event_name: str, sync: bool = True) -> EventItem: ...
777
+ def get_events(self, event_name: str, sync: bool = True) -> EventItem:
778
+ if not sync:
779
+ return _EVENTS_THREADS.run(self.get_events, event_name)
714
780
  try:
715
781
  return self.events[event_name]
716
782
  except EventLookupError:
@@ -727,6 +793,7 @@ class TreasuryTx(DbEntity):
727
793
  return get_transaction(self.hash)
728
794
 
729
795
  @staticmethod
796
+ @auto_retry
730
797
  async def insert(entry: LedgerEntry) -> None:
731
798
  """Asynchronously insert and sort a ledger entry.
732
799
 
@@ -747,8 +814,16 @@ class TreasuryTx(DbEntity):
747
814
  async with _SORT_SEMAPHORE:
748
815
  from dao_treasury.sorting import sort_advanced
749
816
 
750
- with db_session:
817
+ try:
751
818
  await sort_advanced(TreasuryTx[txid])
819
+ except Exception as e:
820
+ e.args = *e.args, entry
821
+ raise
822
+
823
+ async def _set_txgroup(self, txgroup_dbid: TxGroupDbid) -> None:
824
+ await _SORT_THREAD.run(
825
+ TreasuryTx.__set_txgroup, self.treasury_tx_id, txgroup_dbid
826
+ )
752
827
 
753
828
  @staticmethod
754
829
  def __insert(entry: LedgerEntry, ts: int) -> typing.Optional[int]:
@@ -810,9 +885,74 @@ class TreasuryTx(DbEntity):
810
885
  gas_price=gas_price,
811
886
  txgroup=txgroup_dbid,
812
887
  )
888
+ # we must commit here or else dbid below will be `None`.
889
+ commit()
813
890
  dbid = entity.treasury_tx_id
891
+ except InterfaceError as e:
892
+ raise ValueError(
893
+ e,
894
+ {
895
+ "chain": Chain.get_dbid(CHAINID),
896
+ "block": entry.block_number,
897
+ "timestamp": ts,
898
+ "hash": entry.hash.hex(),
899
+ "log_index": log_index,
900
+ "from_address": from_address,
901
+ "to_address": to_address,
902
+ "token": token,
903
+ "amount": entry.value,
904
+ "price": entry.price,
905
+ "value_usd": entry.value_usd,
906
+ # TODO: nuke db and add this column
907
+ # gas = gas,
908
+ "gas_used": gas_used,
909
+ "gas_price": gas_price,
910
+ "txgroup": txgroup_dbid,
911
+ },
912
+ ) from e
814
913
  except InvalidOperation as e:
815
- logger.error(e)
914
+ with db_session:
915
+ from_address_entity = Address[from_address]
916
+ to_address_entity = Address[to_address]
917
+ token_entity = Token[token]
918
+ logger.error(e)
919
+ logger.error(
920
+ {
921
+ "chain": Chain.get_dbid(CHAINID),
922
+ "block": entry.block_number,
923
+ "timestamp": ts,
924
+ "hash": entry.hash.hex(),
925
+ "log_index": log_index,
926
+ "from_address": {
927
+ "dbid": from_address,
928
+ "address": from_address_entity.address,
929
+ "nickname": from_address_entity.nickname,
930
+ },
931
+ "to_address": {
932
+ "dbid": to_address,
933
+ "address": to_address_entity.address,
934
+ "nickname": to_address_entity.nickname,
935
+ },
936
+ "token": {
937
+ "dbid": token,
938
+ "address": token_entity.address.address,
939
+ "name": token_entity.name,
940
+ "symbol": token_entity.symbol,
941
+ "decimals": token_entity.decimals,
942
+ },
943
+ "amount": entry.value,
944
+ "price": entry.price,
945
+ "value_usd": entry.value_usd,
946
+ # TODO: nuke db and add this column
947
+ # gas = gas,
948
+ "gas_used": gas_used,
949
+ "gas_price": gas_price,
950
+ "txgroup": {
951
+ "dbid": txgroup_dbid,
952
+ "fullname": TxGroup[txgroup_dbid].fullname,
953
+ },
954
+ }
955
+ )
816
956
  return None
817
957
  except TransactionIntegrityError as e:
818
958
  return _validate_integrity_error(entry, log_index)
@@ -830,6 +970,158 @@ class TreasuryTx(DbEntity):
830
970
  return None
831
971
  return dbid # type: ignore [no-any-return]
832
972
 
973
+ @staticmethod
974
+ @retry_locked
975
+ def __set_txgroup(treasury_tx_dbid: int, txgroup_dbid: TxGroupDbid) -> None:
976
+ with db_session:
977
+ TreasuryTx[treasury_tx_dbid].txgroup = txgroup_dbid
978
+ commit()
979
+
980
+
981
+ _stream_metadata_cache: Final[Dict[HexStr, Tuple[ChecksumAddress, date]]] = {}
982
+
983
+
984
+ class Stream(DbEntity):
985
+ _table_ = "streams"
986
+ stream_id = PrimaryKey(str)
987
+
988
+ contract = Required("Address", reverse="streams")
989
+ start_block = Required(int)
990
+ end_block = Optional(int)
991
+ token = Required("Token", reverse="streams", index=True)
992
+ from_address = Required("Address", reverse="streams_from")
993
+ to_address = Required("Address", reverse="streams_to")
994
+ reason = Optional(str)
995
+ amount_per_second = Required(Decimal, 38, 1)
996
+ status = Required(str, default="Active")
997
+ txgroup = Optional("TxGroup", reverse="streams")
998
+
999
+ streamed_funds = Set("StreamedFunds", lazy=True)
1000
+
1001
+ scale = 10**20
1002
+
1003
+ @property
1004
+ def is_alive(self) -> bool:
1005
+ if self.end_block is None:
1006
+ assert self.status in ["Active", "Paused"]
1007
+ return self.status == "Active"
1008
+ assert self.status == "Stopped"
1009
+ return False
1010
+
1011
+ @property
1012
+ def amount_per_minute(self) -> int:
1013
+ return self.amount_per_second * 60
1014
+
1015
+ @property
1016
+ def amount_per_hour(self) -> int:
1017
+ return self.amount_per_minute * 60
1018
+
1019
+ @property
1020
+ def amount_per_day(self) -> int:
1021
+ return self.amount_per_hour * 24
1022
+
1023
+ @staticmethod
1024
+ def check_closed(stream_id: HexStr) -> bool:
1025
+ with db_session:
1026
+ return any(sf.is_last_day for sf in Stream[stream_id].streamed_funds)
1027
+
1028
+ @staticmethod
1029
+ def _get_start_and_end(stream_dbid: HexStr) -> Tuple[datetime, datetime]:
1030
+ with db_session:
1031
+ stream = Stream[stream_dbid]
1032
+ start_date, end = stream.start_date, datetime.now(_UTC)
1033
+ # convert start to datetime
1034
+ start = datetime.combine(start_date, time(tzinfo=_UTC), tzinfo=_UTC)
1035
+ if stream.end_block:
1036
+ end = datetime.fromtimestamp(chain[stream.end_block].timestamp, tz=_UTC)
1037
+ return start, end
1038
+
1039
+ def stop_stream(self, block: int) -> None:
1040
+ self.end_block = block
1041
+ self.status = "Stopped"
1042
+
1043
+ def pause(self) -> None:
1044
+ self.status = "Paused"
1045
+
1046
+ @staticmethod
1047
+ def _get_token_and_start_date(stream_id: HexStr) -> Tuple[ChecksumAddress, date]:
1048
+ try:
1049
+ return _stream_metadata_cache[stream_id]
1050
+ except KeyError:
1051
+ with db_session:
1052
+ stream = Stream[stream_id]
1053
+ token = stream.token.address.address
1054
+ start_date = stream.start_date
1055
+ _stream_metadata_cache[stream_id] = token, start_date
1056
+ return token, start_date
1057
+
1058
+ @property
1059
+ def stream_contract(self) -> Contract:
1060
+ return Contract(self.contract.address)
1061
+
1062
+ @property
1063
+ def start_date(self) -> date:
1064
+ return datetime.fromtimestamp(chain[self.start_block].timestamp).date()
1065
+
1066
+ async def amount_withdrawable(self, block: int) -> int:
1067
+ return await self.stream_contract.withdrawable.coroutine(
1068
+ self.from_address.address,
1069
+ self.to_address.address,
1070
+ int(self.amount_per_second),
1071
+ block_identifier=block,
1072
+ )
1073
+
1074
+ def print(self) -> None:
1075
+ symbol = self.token.symbol
1076
+ print(f"{symbol} per second: {self.amount_per_second / self.scale}")
1077
+ print(f"{symbol} per day: {self.amount_per_day / self.scale}")
1078
+
1079
+
1080
+ class StreamedFunds(DbEntity):
1081
+ """Each object represents one calendar day of tokens streamed for a particular stream."""
1082
+
1083
+ _table_ = "streamed_funds"
1084
+
1085
+ date = Required(date)
1086
+ stream = Required(Stream, reverse="streamed_funds")
1087
+ PrimaryKey(stream, date)
1088
+
1089
+ amount = Required(Decimal, 38, 18)
1090
+ price = Required(Decimal, 38, 18)
1091
+ value_usd = Required(Decimal, 38, 18)
1092
+ seconds_active = Required(int)
1093
+ is_last_day = Required(bool)
1094
+
1095
+ @db_session
1096
+ def get_entity(stream_id: str, date: datetime) -> "StreamedFunds":
1097
+ stream = Stream[stream_id]
1098
+ return StreamedFunds.get(date=date, stream=stream)
1099
+
1100
+ @classmethod
1101
+ @db_session
1102
+ def create_entity(
1103
+ cls,
1104
+ stream_id: str,
1105
+ date: datetime,
1106
+ price: Decimal,
1107
+ seconds_active: int,
1108
+ is_last_day: bool,
1109
+ ) -> "StreamedFunds":
1110
+ stream = Stream[stream_id]
1111
+ amount_streamed_today = round(
1112
+ stream.amount_per_second * seconds_active / stream.scale, 18
1113
+ )
1114
+ entity = StreamedFunds(
1115
+ date=date,
1116
+ stream=stream,
1117
+ amount=amount_streamed_today,
1118
+ price=round(price, 18),
1119
+ value_usd=round(amount_streamed_today * price, 18),
1120
+ seconds_active=seconds_active,
1121
+ is_last_day=is_last_day,
1122
+ )
1123
+ return entity
1124
+
833
1125
 
834
1126
  db.bind(
835
1127
  provider="sqlite", # TODO: let user choose postgres with server connection params
@@ -856,19 +1148,19 @@ def create_stream_ledger_view() -> None:
856
1148
  Examples:
857
1149
  >>> create_stream_ledger_view()
858
1150
  """
1151
+ db.execute("""DROP VIEW IF EXISTS stream_ledger;""")
859
1152
  db.execute(
860
1153
  """
861
- DROP VIEW IF EXISTS stream_ledger;
862
1154
  create view stream_ledger as
863
1155
  SELECT 'Mainnet' as chain_name,
864
- cast(DATE AS timestamp) as timestamp,
1156
+ cast(strftime('%s', date || ' 00:00:00') as INTEGER) as timestamp,
865
1157
  NULL as block,
866
1158
  NULL as hash,
867
1159
  NULL as log_index,
868
1160
  symbol as token,
869
1161
  d.address AS "from",
870
1162
  d.nickname as from_nickname,
871
- e.address as "to",
1163
+ e.address AS "to",
872
1164
  e.nickname as to_nickname,
873
1165
  amount,
874
1166
  price,
@@ -887,6 +1179,31 @@ def create_stream_ledger_view() -> None:
887
1179
  )
888
1180
 
889
1181
 
1182
+ def create_txgroup_hierarchy_view() -> None:
1183
+ """Create or replace the SQL view `txgroup_hierarchy` for recursive txgroup hierarchy.
1184
+
1185
+ This view exposes txgroup_id, top_category, and parent_txgroup for all txgroups,
1186
+ matching the recursive CTE logic used in dashboards.
1187
+ """
1188
+ db.execute("DROP VIEW IF EXISTS txgroup_hierarchy;")
1189
+ db.execute(
1190
+ """
1191
+ CREATE VIEW txgroup_hierarchy AS
1192
+ WITH RECURSIVE group_hierarchy (txgroup_id, top_category, parent_txgroup) AS (
1193
+ SELECT txgroup_id, name AS top_category, parent_txgroup
1194
+ FROM txgroups
1195
+ WHERE parent_txgroup IS NULL
1196
+ UNION ALL
1197
+ SELECT child.txgroup_id, parent.top_category, child.parent_txgroup
1198
+ FROM txgroups AS child
1199
+ JOIN group_hierarchy AS parent
1200
+ ON child.parent_txgroup = parent.txgroup_id
1201
+ )
1202
+ SELECT * FROM group_hierarchy;
1203
+ """
1204
+ )
1205
+
1206
+
890
1207
  def create_vesting_ledger_view() -> None:
891
1208
  """Create or replace the SQL view `vesting_ledger` for vesting escrow reporting.
892
1209
 
@@ -942,7 +1259,7 @@ def create_general_ledger_view() -> None:
942
1259
  create VIEW general_ledger as
943
1260
  select *
944
1261
  from (
945
- SELECT treasury_tx_id, b.chain_name, datetime(a.timestamp, 'unixepoch') AS timestamp, a.block, a.hash, a.log_index, c.symbol AS token, d.address AS "from", d.nickname as from_nickname, e.address AS "to", e.nickname as to_nickname, a.amount, a.price, a.value_usd, f.name AS txgroup, g.name AS parent_txgroup, f.txgroup_id
1262
+ SELECT treasury_tx_id, b.chain_name, a.timestamp, a.block, a.hash, a.log_index, c.symbol AS token, d.address AS "from", d.nickname as from_nickname, e.address AS "to", e.nickname as to_nickname, a.amount, a.price, a.value_usd, f.name AS txgroup, g.name AS parent_txgroup, f.txgroup_id
946
1263
  FROM treasury_txs a
947
1264
  LEFT JOIN chains b ON a.chain = b.chain_dbid
948
1265
  LEFT JOIN tokens c ON a.token_id = c.token_id
@@ -950,9 +1267,9 @@ def create_general_ledger_view() -> None:
950
1267
  LEFT JOIN addresses e ON a."to" = e.address_id
951
1268
  LEFT JOIN txgroups f ON a.txgroup_id = f.txgroup_id
952
1269
  LEFT JOIN txgroups g ON f.parent_txgroup = g.txgroup_id
953
- --UNION
954
- --SELECT -1, chain_name, TIMESTAMP, cast(block AS integer) block, hash, CAST(log_index AS integer) as log_index, token, "from", from_nickname, "to", to_nickname, amount, price, value_usd, txgroup, parent_txgroup, txgroup_id
955
- --FROM stream_ledger
1270
+ UNION
1271
+ SELECT -1, chain_name, timestamp, block, hash, log_index, token, "from", from_nickname, "to", to_nickname, amount, price, value_usd, txgroup, parent_txgroup, txgroup_id
1272
+ FROM stream_ledger
956
1273
  --UNION
957
1274
  --SELECT -1, *
958
1275
  --FROM vesting_ledger
@@ -1028,7 +1345,8 @@ def create_monthly_pnl_view() -> None:
1028
1345
 
1029
1346
 
1030
1347
  with db_session:
1031
- # create_stream_ledger_view()
1348
+ create_stream_ledger_view()
1349
+ create_txgroup_hierarchy_view()
1032
1350
  # create_vesting_ledger_view()
1033
1351
  create_general_ledger_view()
1034
1352
  create_unsorted_txs_view()
@@ -1088,7 +1406,7 @@ def _validate_integrity_error(
1088
1406
  existing_object.amount,
1089
1407
  )
1090
1408
  except AssertionError:
1091
- logger.warning(
1409
+ logger.debug(
1092
1410
  "slight rounding error in value for TreasuryTx[%s] due to sqlite decimal handling",
1093
1411
  existing_object.treasury_tx_id,
1094
1412
  )
@@ -1113,3 +1431,24 @@ def _validate_integrity_error(
1113
1431
  )
1114
1432
  else None
1115
1433
  )
1434
+
1435
+
1436
+ def _drop_shitcoin_txs() -> None:
1437
+ """
1438
+ Purge any shitcoin txs from the db.
1439
+
1440
+ These should not be frequent, and only occur if a user populated the db before a shitcoin was added to the SHITCOINS mapping.
1441
+ """
1442
+ shitcoins = eth_portfolio.SHITCOINS[CHAINID]
1443
+ with db_session:
1444
+ shitcoin_txs = select(
1445
+ tx for tx in TreasuryTx if tx.token.address.address in shitcoins
1446
+ )
1447
+ if count := shitcoin_txs.count():
1448
+ logger.info(f"Purging {count} shitcoin txs from the database...")
1449
+ for tx in shitcoin_txs:
1450
+ tx.delete()
1451
+ logger.info("Shitcoin tx purge complete.")
1452
+
1453
+
1454
+ _drop_shitcoin_txs()