dao-treasury 0.0.17__cp312-cp312-win32.whl → 0.0.61__cp312-cp312-win32.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.

Potentially problematic release.


This version of dao-treasury might be problematic. Click here for more details.

Files changed (56) hide show
  1. dao_treasury/.grafana/provisioning/dashboards/breakdowns/Expenses.json +526 -0
  2. dao_treasury/.grafana/provisioning/dashboards/breakdowns/Revenue.json +526 -0
  3. dao_treasury/.grafana/provisioning/dashboards/dashboards.yaml +76 -2
  4. dao_treasury/.grafana/provisioning/dashboards/streams/LlamaPay.json +225 -0
  5. dao_treasury/.grafana/provisioning/dashboards/summary/Monthly.json +13 -17
  6. dao_treasury/.grafana/provisioning/dashboards/transactions/Treasury Transactions.json +167 -19
  7. dao_treasury/.grafana/provisioning/dashboards/treasury/Cashflow (Including Unsorted).json +876 -0
  8. dao_treasury/.grafana/provisioning/dashboards/treasury/Cashflow.json +645 -0
  9. dao_treasury/.grafana/provisioning/dashboards/treasury/Current Treasury Assets.json +593 -0
  10. dao_treasury/.grafana/provisioning/dashboards/treasury/Historical Treasury Balances.json +2999 -0
  11. dao_treasury/.grafana/provisioning/dashboards/treasury/Operating Cashflow.json +513 -0
  12. dao_treasury/.grafana/provisioning/datasources/datasources.yaml +17 -0
  13. dao_treasury/ENVIRONMENT_VARIABLES.py +20 -0
  14. dao_treasury/__init__.py +24 -0
  15. dao_treasury/_docker.cp312-win32.pyd +0 -0
  16. dao_treasury/_docker.py +48 -23
  17. dao_treasury/_nicknames.cp312-win32.pyd +0 -0
  18. dao_treasury/_nicknames.py +32 -0
  19. dao_treasury/_wallet.cp312-win32.pyd +0 -0
  20. dao_treasury/_wallet.py +162 -10
  21. dao_treasury/constants.cp312-win32.pyd +0 -0
  22. dao_treasury/constants.py +39 -0
  23. dao_treasury/db.py +429 -57
  24. dao_treasury/docker-compose.yaml +6 -5
  25. dao_treasury/main.py +102 -13
  26. dao_treasury/sorting/__init__.cp312-win32.pyd +0 -0
  27. dao_treasury/sorting/__init__.py +181 -105
  28. dao_treasury/sorting/_matchers.cp312-win32.pyd +0 -0
  29. dao_treasury/sorting/_rules.cp312-win32.pyd +0 -0
  30. dao_treasury/sorting/_rules.py +1 -3
  31. dao_treasury/sorting/factory.cp312-win32.pyd +0 -0
  32. dao_treasury/sorting/factory.py +2 -6
  33. dao_treasury/sorting/rule.cp312-win32.pyd +0 -0
  34. dao_treasury/sorting/rule.py +16 -13
  35. dao_treasury/sorting/rules/__init__.cp312-win32.pyd +0 -0
  36. dao_treasury/sorting/rules/__init__.py +1 -0
  37. dao_treasury/sorting/rules/ignore/__init__.cp312-win32.pyd +0 -0
  38. dao_treasury/sorting/rules/ignore/__init__.py +1 -0
  39. dao_treasury/sorting/rules/ignore/llamapay.cp312-win32.pyd +0 -0
  40. dao_treasury/sorting/rules/ignore/llamapay.py +20 -0
  41. dao_treasury/streams/__init__.cp312-win32.pyd +0 -0
  42. dao_treasury/streams/__init__.py +0 -0
  43. dao_treasury/streams/llamapay.cp312-win32.pyd +0 -0
  44. dao_treasury/streams/llamapay.py +388 -0
  45. dao_treasury/treasury.py +75 -28
  46. dao_treasury/types.cp312-win32.pyd +0 -0
  47. dao_treasury-0.0.61.dist-info/METADATA +120 -0
  48. dao_treasury-0.0.61.dist-info/RECORD +54 -0
  49. dao_treasury-0.0.61.dist-info/top_level.txt +2 -0
  50. dao_treasury__mypyc.cp312-win32.pyd +0 -0
  51. 52b51d40e96d4333695d__mypyc.cp312-win32.pyd +0 -0
  52. dao_treasury/.grafana/provisioning/datasources/sqlite.yaml +0 -10
  53. dao_treasury-0.0.17.dist-info/METADATA +0 -36
  54. dao_treasury-0.0.17.dist-info/RECORD +0 -30
  55. dao_treasury-0.0.17.dist-info/top_level.txt +0 -2
  56. {dao_treasury-0.0.17.dist-info → dao_treasury-0.0.61.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,27 +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
45
+ from brownie.exceptions import EventLookupError
29
46
  from brownie.network.event import EventDict, _EventItem
30
47
  from brownie.network.transaction import TransactionReceipt
31
- from eth_typing import ChecksumAddress, HexAddress
32
48
  from eth_portfolio.structs import (
33
49
  InternalTransfer,
34
50
  LedgerEntry,
35
51
  TokenTransfer,
36
52
  Transaction,
37
53
  )
54
+ from eth_retry import auto_retry
55
+ from eth_typing import ChecksumAddress, HexAddress, HexStr
38
56
  from pony.orm import (
39
57
  Database,
58
+ InterfaceError,
40
59
  Optional,
41
60
  PrimaryKey,
42
61
  Required,
@@ -46,15 +65,20 @@ from pony.orm import (
46
65
  composite_key,
47
66
  composite_index,
48
67
  db_session,
68
+ select,
49
69
  )
50
70
  from y import EEE_ADDRESS, Contract, Network, convert, get_block_timestamp_async
51
- from y.constants import CHAINID
71
+ from y._db.decorators import retry_locked
52
72
  from y.contracts import _get_code
53
73
  from y.exceptions import ContractNotVerified
54
74
 
75
+ from dao_treasury.constants import CHAINID
55
76
  from dao_treasury.types import TxGroupDbid, TxGroupName
56
77
 
57
78
 
79
+ EventItem = _EventItem[_EventItem[OrderedDict[str, Any]]]
80
+
81
+
58
82
  SQLITE_DIR = Path(path.expanduser("~")) / ".dao-treasury"
59
83
  """Path to the directory in the user's home where the DAO treasury SQLite database is stored."""
60
84
 
@@ -62,8 +86,11 @@ SQLITE_DIR.mkdir(parents=True, exist_ok=True)
62
86
 
63
87
 
64
88
  _INSERT_THREAD = AsyncThreadPoolExecutor(1)
89
+ _SORT_THREAD = AsyncThreadPoolExecutor(1)
90
+ _EVENTS_THREADS = AsyncThreadPoolExecutor(16)
65
91
  _SORT_SEMAPHORE = Semaphore(50)
66
92
 
93
+ _UTC = timezone.utc
67
94
 
68
95
  db = Database()
69
96
 
@@ -112,13 +139,13 @@ class Chain(DbEntity):
112
139
  chainid = Required(int, unique=True)
113
140
  """Numeric chain ID matching the connected RPC via :data:`~y.constants.CHAINID`."""
114
141
 
115
- addresses = Set("Address", reverse="chain")
142
+ addresses = Set("Address", reverse="chain", lazy=True)
116
143
  """Relationship to address records on this chain."""
117
144
 
118
- tokens = Set("Token", reverse="chain")
145
+ tokens = Set("Token", reverse="chain", lazy=True)
119
146
  """Relationship to token records on this chain."""
120
147
 
121
- treasury_txs = Set("TreasuryTx")
148
+ treasury_txs = Set("TreasuryTx", lazy=True)
122
149
  """Relationship to treasury transactions on this chain."""
123
150
 
124
151
  @staticmethod
@@ -177,7 +204,7 @@ class Address(DbEntity):
177
204
  address_id = PrimaryKey(int, auto=True)
178
205
  """Auto-incremented primary key for the addresses table."""
179
206
 
180
- chain = Required(Chain, reverse="addresses")
207
+ chain = Required(Chain, reverse="addresses", lazy=True)
181
208
  """Reference to the chain on which this address resides."""
182
209
 
183
210
  address = Required(str, index=True)
@@ -186,7 +213,7 @@ class Address(DbEntity):
186
213
  nickname = Optional(str)
187
214
  """Optional human-readable label (e.g., contract name or token name)."""
188
215
 
189
- is_contract = Required(bool, index=True)
216
+ is_contract = Required(bool, index=True, lazy=True)
190
217
  """Flag indicating whether the address is a smart contract."""
191
218
 
192
219
  composite_key(address, chain)
@@ -197,29 +224,40 @@ class Address(DbEntity):
197
224
  treasury_tx_from: Set["TreasuryTx"]
198
225
  treasury_tx_to: Set["TreasuryTx"]
199
226
 
200
- token = Optional("Token", index=True)
227
+ token = Optional("Token", index=True, lazy=True)
201
228
  """Optional back-reference to a Token if this address is one."""
202
- # partners_tx = Set('PartnerHarvestEvent', reverse='wrapper')
229
+ # partners_tx = Set('PartnerHarvestEvent', reverse='wrapper', lazy=True)
203
230
 
204
- treasury_tx_from = Set("TreasuryTx", reverse="from_address")
231
+ treasury_tx_from = Set("TreasuryTx", reverse="from_address", lazy=True)
205
232
  """Inverse relation for transactions sent from this address."""
206
233
 
207
- treasury_tx_to = Set("TreasuryTx", reverse="to_address")
234
+ treasury_tx_to = Set("TreasuryTx", reverse="to_address", lazy=True)
208
235
  """Inverse relation for transactions sent to this address."""
209
- # streams_from = Set("Stream", reverse="from_address")
210
- # streams_to = Set("Stream", reverse="to_address")
211
- # streams = Set("Stream", reverse="contract")
212
- # vesting_escrows = Set("VestingEscrow", reverse="address")
213
- # vests_received = Set("VestingEscrow", reverse="recipient")
214
- # vests_funded = Set("VestingEscrow", reverse="funder")
215
-
216
- def __eq__(self, other: Union["Address", ChecksumAddress]) -> bool: # type: ignore [override]
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)
243
+
244
+ def __eq__(self, other: Union["Address", ChecksumAddress, "Token"]) -> bool: # type: ignore [override]
217
245
  if isinstance(other, str):
218
246
  return CHAINID == self.chain.chainid and other == self.address
247
+ elif isinstance(other, Token):
248
+ return self.address_id == other.address.address_id
219
249
  return super().__eq__(other)
220
250
 
221
251
  __hash__ = DbEntity.__hash__
222
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
+
223
261
  @staticmethod
224
262
  @lru_cache(maxsize=None)
225
263
  def get_dbid(address: HexAddress) -> int:
@@ -256,11 +294,13 @@ class Address(DbEntity):
256
294
  if entity := Address.get(chain=chain_dbid, address=checksum_address):
257
295
  return entity # type: ignore [no-any-return]
258
296
 
259
- if _get_code(address, None).hex().removeprefix("0x"):
297
+ if _get_code(checksum_address, None).hex().removeprefix("0x"):
260
298
  try:
261
- nickname = f"Contract: {Contract(address)._build['contractName']}"
299
+ nickname = (
300
+ f"Contract: {Contract(checksum_address)._build['contractName']}"
301
+ )
262
302
  except ContractNotVerified:
263
- nickname = f"Non-Verified Contract: {address}"
303
+ nickname = f"Non-Verified Contract: {checksum_address}"
264
304
 
265
305
  entity = Address(
266
306
  chain=chain_dbid,
@@ -278,9 +318,34 @@ class Address(DbEntity):
278
318
  )
279
319
 
280
320
  commit()
281
-
282
321
  return entity # type: ignore [no-any-return]
283
322
 
323
+ @staticmethod
324
+ def set_nickname(address: HexAddress, nickname: str) -> None:
325
+ if not nickname:
326
+ raise ValueError("You must provide an actual string")
327
+ with db_session:
328
+ entity = Address.get_or_insert(address)
329
+ if entity.nickname == nickname:
330
+ return
331
+ if entity.nickname:
332
+ old = entity.nickname
333
+ entity.nickname = nickname
334
+ commit()
335
+ logger.info(
336
+ "%s nickname changed from %s to %s", entity.address, old, nickname
337
+ )
338
+ else:
339
+ entity.nickname = nickname
340
+ commit()
341
+ logger.info("%s nickname set to %s", entity.address, nickname)
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
+
284
349
 
285
350
  UNI_V3_POS: Final = {
286
351
  Network.Mainnet: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
@@ -325,34 +390,37 @@ class Token(DbEntity):
325
390
  token_id = PrimaryKey(int, auto=True)
326
391
  """Auto-incremented primary key for the tokens table."""
327
392
 
328
- chain = Required(Chain, index=True)
393
+ chain = Required(Chain, index=True, lazy=True)
329
394
  """Foreign key linking to :class:`~dao_treasury.db.Chain`."""
330
395
 
331
- symbol = Required(str, index=True)
396
+ symbol = Required(str, index=True, lazy=True)
332
397
  """Short ticker symbol for the token."""
333
398
 
334
- name = Required(str)
399
+ name = Required(str, lazy=True)
335
400
  """Full human-readable name of the token."""
336
401
 
337
- decimals = Required(int)
402
+ decimals = Required(int, lazy=True)
338
403
  """Number of decimals used for value scaling."""
339
404
 
340
405
  if TYPE_CHECKING:
341
406
  treasury_tx: Set["TreasuryTx"]
342
407
 
343
- treasury_tx = Set("TreasuryTx", reverse="token")
408
+ treasury_tx = Set("TreasuryTx", reverse="token", lazy=True)
344
409
  """Inverse relation for treasury transactions involving this token."""
345
- # partner_harvest_event = Set('PartnerHarvestEvent', reverse="vault")
410
+ # partner_harvest_event = Set('PartnerHarvestEvent', reverse="vault", lazy=True)
346
411
 
347
412
  address = Required(Address, column="address_id")
348
413
  """Foreign key to the address record for this token contract."""
349
- # streams = Set('Stream', reverse="token")
350
- # vesting_escrows = Set("VestingEscrow", reverse="token")
351
414
 
352
- def __eq__(self, other: Union["Token", ChecksumAddress]) -> bool: # type: ignore [override]
353
- return (
354
- self.address == other if isinstance(other, str) else super().__eq__(other)
355
- )
415
+ streams = Set("Stream", reverse="token", lazy=True)
416
+ # vesting_escrows = Set("VestingEscrow", reverse="token", lazy=True)
417
+
418
+ def __eq__(self, other: Union["Token", Address, ChecksumAddress]) -> bool: # type: ignore [override]
419
+ if isinstance(other, str):
420
+ return self.address == other
421
+ elif isinstance(other, Address):
422
+ return self.address.address_id == other.address_id
423
+ return super().__eq__(other)
356
424
 
357
425
  __hash__ = DbEntity.__hash__
358
426
 
@@ -360,6 +428,10 @@ class Token(DbEntity):
360
428
  def contract(self) -> Contract:
361
429
  return Contract(self.address.address)
362
430
 
431
+ @property
432
+ def contract_coro(self) -> Coroutine[Any, Any, Contract]:
433
+ return Contract.coroutine(self.address.address)
434
+
363
435
  @property
364
436
  def scale(self) -> int:
365
437
  """Base for division according to `decimals`, e.g., `10**decimals`.
@@ -488,7 +560,7 @@ class TxGroup(DbEntity):
488
560
  name = Required(str)
489
561
  """Name of the grouping category, e.g., 'Revenue', 'Expenses'."""
490
562
 
491
- treasury_tx = Set("TreasuryTx", reverse="txgroup")
563
+ treasury_tx = Set("TreasuryTx", reverse="txgroup", lazy=True)
492
564
  """Inverse relation for treasury transactions assigned to this group."""
493
565
 
494
566
  parent_txgroup = Optional("TxGroup", reverse="child_txgroups")
@@ -496,11 +568,13 @@ class TxGroup(DbEntity):
496
568
 
497
569
  composite_key(name, parent_txgroup)
498
570
 
499
- child_txgroups = Set("TxGroup", reverse="parent_txgroup")
571
+ child_txgroups = Set("TxGroup", reverse="parent_txgroup", lazy=True)
500
572
  """Set of nested child groups."""
501
- # TODO: implement these
502
- # streams = Set("Stream", reverse="txgroup")
503
- # 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)
504
578
 
505
579
  @property
506
580
  def fullname(self) -> str:
@@ -576,7 +650,7 @@ class TxGroup(DbEntity):
576
650
  return txgroup # type: ignore [no-any-return]
577
651
 
578
652
 
579
- @lru_cache(100)
653
+ @lru_cache(500)
580
654
  def get_transaction(txhash: str) -> TransactionReceipt:
581
655
  """Fetch and cache a transaction receipt from the connected chain.
582
656
 
@@ -682,14 +756,32 @@ class TreasuryTx(DbEntity):
682
756
  """Decoded event logs for this transaction."""
683
757
  return self._transaction.events
684
758
 
685
- def get_events(self, event_name: str) -> _EventItem:
759
+ async def events_async(self) -> EventDict:
760
+ """Asynchronously fetch decoded event logs for this transaction."""
761
+ tx = self._transaction
762
+ events = tx._events
763
+ if events is None:
764
+ events = await _EVENTS_THREADS.run(getattr, tx, "events")
765
+ return events
766
+
767
+ @overload
768
+ def get_events(
769
+ self, event_name: str, sync: Literal[False]
770
+ ) -> Coroutine[Any, Any, EventItem]: ...
771
+ @overload
772
+ def get_events(self, event_name: str, sync: bool = True) -> EventItem: ...
773
+ def get_events(self, event_name: str, sync: bool = True) -> EventItem:
774
+ if not sync:
775
+ return _EVENTS_THREADS.run(self.get_events, event_name)
686
776
  try:
687
777
  return self.events[event_name]
778
+ except EventLookupError:
779
+ pass
688
780
  except KeyError as e:
689
781
  # This happens sometimes due to a busted abi and hopefully shouldnt impact you
690
- if str(e) == "'components'":
691
- return _EventItem(event_name, None, [], ())
692
- raise
782
+ if str(e) != "'components'":
783
+ raise
784
+ return _EventItem(event_name, None, [], ())
693
785
 
694
786
  @property
695
787
  def _transaction(self) -> TransactionReceipt:
@@ -697,6 +789,7 @@ class TreasuryTx(DbEntity):
697
789
  return get_transaction(self.hash)
698
790
 
699
791
  @staticmethod
792
+ @auto_retry
700
793
  async def insert(entry: LedgerEntry) -> None:
701
794
  """Asynchronously insert and sort a ledger entry.
702
795
 
@@ -717,8 +810,16 @@ class TreasuryTx(DbEntity):
717
810
  async with _SORT_SEMAPHORE:
718
811
  from dao_treasury.sorting import sort_advanced
719
812
 
720
- with db_session:
813
+ try:
721
814
  await sort_advanced(TreasuryTx[txid])
815
+ except Exception as e:
816
+ e.args = *e.args, entry
817
+ raise
818
+
819
+ async def _set_txgroup(self, txgroup_dbid: TxGroupDbid) -> None:
820
+ await _SORT_THREAD.run(
821
+ TreasuryTx.__set_txgroup, self.treasury_tx_id, txgroup_dbid
822
+ )
722
823
 
723
824
  @staticmethod
724
825
  def __insert(entry: LedgerEntry, ts: int) -> typing.Optional[int]:
@@ -780,9 +881,74 @@ class TreasuryTx(DbEntity):
780
881
  gas_price=gas_price,
781
882
  txgroup=txgroup_dbid,
782
883
  )
884
+ # we must commit here or else dbid below will be `None`.
885
+ commit()
783
886
  dbid = entity.treasury_tx_id
887
+ except InterfaceError as e:
888
+ raise ValueError(
889
+ e,
890
+ {
891
+ "chain": Chain.get_dbid(CHAINID),
892
+ "block": entry.block_number,
893
+ "timestamp": ts,
894
+ "hash": entry.hash.hex(),
895
+ "log_index": log_index,
896
+ "from_address": from_address,
897
+ "to_address": to_address,
898
+ "token": token,
899
+ "amount": entry.value,
900
+ "price": entry.price,
901
+ "value_usd": entry.value_usd,
902
+ # TODO: nuke db and add this column
903
+ # gas = gas,
904
+ "gas_used": gas_used,
905
+ "gas_price": gas_price,
906
+ "txgroup": txgroup_dbid,
907
+ },
908
+ ) from e
784
909
  except InvalidOperation as e:
785
- logger.error(e)
910
+ with db_session:
911
+ from_address_entity = Address[from_address]
912
+ to_address_entity = Address[to_address]
913
+ token_entity = Token[token]
914
+ logger.error(e)
915
+ logger.error(
916
+ {
917
+ "chain": Chain.get_dbid(CHAINID),
918
+ "block": entry.block_number,
919
+ "timestamp": ts,
920
+ "hash": entry.hash.hex(),
921
+ "log_index": log_index,
922
+ "from_address": {
923
+ "dbid": from_address,
924
+ "address": from_address_entity.address,
925
+ "nickname": from_address_entity.nickname,
926
+ },
927
+ "to_address": {
928
+ "dbid": to_address,
929
+ "address": to_address_entity.address,
930
+ "nickname": to_address_entity.nickname,
931
+ },
932
+ "token": {
933
+ "dbid": token,
934
+ "address": token_entity.address.address,
935
+ "name": token_entity.name,
936
+ "symbol": token_entity.symbol,
937
+ "decimals": token_entity.decimals,
938
+ },
939
+ "amount": entry.value,
940
+ "price": entry.price,
941
+ "value_usd": entry.value_usd,
942
+ # TODO: nuke db and add this column
943
+ # gas = gas,
944
+ "gas_used": gas_used,
945
+ "gas_price": gas_price,
946
+ "txgroup": {
947
+ "dbid": txgroup_dbid,
948
+ "fullname": TxGroup[txgroup_dbid].fullname,
949
+ },
950
+ }
951
+ )
786
952
  return None
787
953
  except TransactionIntegrityError as e:
788
954
  return _validate_integrity_error(entry, log_index)
@@ -800,6 +966,158 @@ class TreasuryTx(DbEntity):
800
966
  return None
801
967
  return dbid # type: ignore [no-any-return]
802
968
 
969
+ @staticmethod
970
+ @retry_locked
971
+ def __set_txgroup(treasury_tx_dbid: int, txgroup_dbid: TxGroupDbid) -> None:
972
+ with db_session:
973
+ TreasuryTx[treasury_tx_dbid].txgroup = txgroup_dbid
974
+ commit()
975
+
976
+
977
+ _stream_metadata_cache: Final[Dict[HexStr, Tuple[ChecksumAddress, date]]] = {}
978
+
979
+
980
+ class Stream(DbEntity):
981
+ _table_ = "streams"
982
+ stream_id = PrimaryKey(str)
983
+
984
+ contract = Required("Address", reverse="streams")
985
+ start_block = Required(int)
986
+ end_block = Optional(int)
987
+ token = Required("Token", reverse="streams", index=True)
988
+ from_address = Required("Address", reverse="streams_from")
989
+ to_address = Required("Address", reverse="streams_to")
990
+ reason = Optional(str)
991
+ amount_per_second = Required(Decimal, 38, 1)
992
+ status = Required(str, default="Active")
993
+ txgroup = Optional("TxGroup", reverse="streams")
994
+
995
+ streamed_funds = Set("StreamedFunds", lazy=True)
996
+
997
+ scale = 10**20
998
+
999
+ @property
1000
+ def is_alive(self) -> bool:
1001
+ if self.end_block is None:
1002
+ assert self.status in ["Active", "Paused"]
1003
+ return self.status == "Active"
1004
+ assert self.status == "Stopped"
1005
+ return False
1006
+
1007
+ @property
1008
+ def amount_per_minute(self) -> int:
1009
+ return self.amount_per_second * 60
1010
+
1011
+ @property
1012
+ def amount_per_hour(self) -> int:
1013
+ return self.amount_per_minute * 60
1014
+
1015
+ @property
1016
+ def amount_per_day(self) -> int:
1017
+ return self.amount_per_hour * 24
1018
+
1019
+ @staticmethod
1020
+ def check_closed(stream_id: HexStr) -> bool:
1021
+ with db_session:
1022
+ return any(sf.is_last_day for sf in Stream[stream_id].streamed_funds)
1023
+
1024
+ @staticmethod
1025
+ def _get_start_and_end(stream_dbid: HexStr) -> Tuple[datetime, datetime]:
1026
+ with db_session:
1027
+ stream = Stream[stream_dbid]
1028
+ start_date, end = stream.start_date, datetime.now(_UTC)
1029
+ # convert start to datetime
1030
+ start = datetime.combine(start_date, time(tzinfo=_UTC), tzinfo=_UTC)
1031
+ if stream.end_block:
1032
+ end = datetime.fromtimestamp(chain[stream.end_block].timestamp, tz=_UTC)
1033
+ return start, end
1034
+
1035
+ def stop_stream(self, block: int) -> None:
1036
+ self.end_block = block
1037
+ self.status = "Stopped"
1038
+
1039
+ def pause(self) -> None:
1040
+ self.status = "Paused"
1041
+
1042
+ @staticmethod
1043
+ def _get_token_and_start_date(stream_id: HexStr) -> Tuple[ChecksumAddress, date]:
1044
+ try:
1045
+ return _stream_metadata_cache[stream_id]
1046
+ except KeyError:
1047
+ with db_session:
1048
+ stream = Stream[stream_id]
1049
+ token = stream.token.address.address
1050
+ start_date = stream.start_date
1051
+ _stream_metadata_cache[stream_id] = token, start_date
1052
+ return token, start_date
1053
+
1054
+ @property
1055
+ def stream_contract(self) -> Contract:
1056
+ return Contract(self.contract.address)
1057
+
1058
+ @property
1059
+ def start_date(self) -> date:
1060
+ return datetime.fromtimestamp(chain[self.start_block].timestamp).date()
1061
+
1062
+ async def amount_withdrawable(self, block: int) -> int:
1063
+ return await self.stream_contract.withdrawable.coroutine(
1064
+ self.from_address.address,
1065
+ self.to_address.address,
1066
+ int(self.amount_per_second),
1067
+ block_identifier=block,
1068
+ )
1069
+
1070
+ def print(self) -> None:
1071
+ symbol = self.token.symbol
1072
+ print(f"{symbol} per second: {self.amount_per_second / self.scale}")
1073
+ print(f"{symbol} per day: {self.amount_per_day / self.scale}")
1074
+
1075
+
1076
+ class StreamedFunds(DbEntity):
1077
+ """Each object represents one calendar day of tokens streamed for a particular stream."""
1078
+
1079
+ _table_ = "streamed_funds"
1080
+
1081
+ date = Required(date)
1082
+ stream = Required(Stream, reverse="streamed_funds")
1083
+ PrimaryKey(stream, date)
1084
+
1085
+ amount = Required(Decimal, 38, 18)
1086
+ price = Required(Decimal, 38, 18)
1087
+ value_usd = Required(Decimal, 38, 18)
1088
+ seconds_active = Required(int)
1089
+ is_last_day = Required(bool)
1090
+
1091
+ @db_session
1092
+ def get_entity(stream_id: str, date: datetime) -> "StreamedFunds":
1093
+ stream = Stream[stream_id]
1094
+ return StreamedFunds.get(date=date, stream=stream)
1095
+
1096
+ @classmethod
1097
+ @db_session
1098
+ def create_entity(
1099
+ cls,
1100
+ stream_id: str,
1101
+ date: datetime,
1102
+ price: Decimal,
1103
+ seconds_active: int,
1104
+ is_last_day: bool,
1105
+ ) -> "StreamedFunds":
1106
+ stream = Stream[stream_id]
1107
+ amount_streamed_today = round(
1108
+ stream.amount_per_second * seconds_active / stream.scale, 18
1109
+ )
1110
+ entity = StreamedFunds(
1111
+ date=date,
1112
+ stream=stream,
1113
+ amount=amount_streamed_today,
1114
+ price=round(price, 18),
1115
+ value_usd=round(amount_streamed_today * price, 18),
1116
+ seconds_active=seconds_active,
1117
+ is_last_day=is_last_day,
1118
+ )
1119
+ return entity
1120
+
803
1121
 
804
1122
  db.bind(
805
1123
  provider="sqlite", # TODO: let user choose postgres with server connection params
@@ -810,6 +1128,13 @@ db.bind(
810
1128
  db.generate_mapping(create_tables=True)
811
1129
 
812
1130
 
1131
+ def _set_address_nicknames_for_tokens() -> None:
1132
+ """Set address.nickname for addresses belonging to tokens."""
1133
+ for address in select(a for a in Address if a.token and not a.nickname):
1134
+ address.nickname = f"Token: {address.token.name}"
1135
+ db.commit()
1136
+
1137
+
813
1138
  def create_stream_ledger_view() -> None:
814
1139
  """Create or replace the SQL view `stream_ledger` for streamed funds reporting.
815
1140
 
@@ -819,19 +1144,19 @@ def create_stream_ledger_view() -> None:
819
1144
  Examples:
820
1145
  >>> create_stream_ledger_view()
821
1146
  """
1147
+ db.execute("""DROP VIEW IF EXISTS stream_ledger;""")
822
1148
  db.execute(
823
1149
  """
824
- DROP VIEW IF EXISTS stream_ledger;
825
1150
  create view stream_ledger as
826
1151
  SELECT 'Mainnet' as chain_name,
827
- cast(DATE AS timestamp) as timestamp,
1152
+ cast(strftime('%s', date || ' 00:00:00') as INTEGER) as timestamp,
828
1153
  NULL as block,
829
1154
  NULL as hash,
830
1155
  NULL as log_index,
831
1156
  symbol as token,
832
1157
  d.address AS "from",
833
1158
  d.nickname as from_nickname,
834
- e.address as "to",
1159
+ e.address AS "to",
835
1160
  e.nickname as to_nickname,
836
1161
  amount,
837
1162
  price,
@@ -850,6 +1175,31 @@ def create_stream_ledger_view() -> None:
850
1175
  )
851
1176
 
852
1177
 
1178
+ def create_txgroup_hierarchy_view() -> None:
1179
+ """Create or replace the SQL view `txgroup_hierarchy` for recursive txgroup hierarchy.
1180
+
1181
+ This view exposes txgroup_id, top_category, and parent_txgroup for all txgroups,
1182
+ matching the recursive CTE logic used in dashboards.
1183
+ """
1184
+ db.execute("DROP VIEW IF EXISTS txgroup_hierarchy;")
1185
+ db.execute(
1186
+ """
1187
+ CREATE VIEW txgroup_hierarchy AS
1188
+ WITH RECURSIVE group_hierarchy (txgroup_id, top_category, parent_txgroup) AS (
1189
+ SELECT txgroup_id, name AS top_category, parent_txgroup
1190
+ FROM txgroups
1191
+ WHERE parent_txgroup IS NULL
1192
+ UNION ALL
1193
+ SELECT child.txgroup_id, parent.top_category, child.parent_txgroup
1194
+ FROM txgroups AS child
1195
+ JOIN group_hierarchy AS parent
1196
+ ON child.parent_txgroup = parent.txgroup_id
1197
+ )
1198
+ SELECT * FROM group_hierarchy;
1199
+ """
1200
+ )
1201
+
1202
+
853
1203
  def create_vesting_ledger_view() -> None:
854
1204
  """Create or replace the SQL view `vesting_ledger` for vesting escrow reporting.
855
1205
 
@@ -905,7 +1255,7 @@ def create_general_ledger_view() -> None:
905
1255
  create VIEW general_ledger as
906
1256
  select *
907
1257
  from (
908
- 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
1258
+ 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
909
1259
  FROM treasury_txs a
910
1260
  LEFT JOIN chains b ON a.chain = b.chain_dbid
911
1261
  LEFT JOIN tokens c ON a.token_id = c.token_id
@@ -913,9 +1263,9 @@ def create_general_ledger_view() -> None:
913
1263
  LEFT JOIN addresses e ON a."to" = e.address_id
914
1264
  LEFT JOIN txgroups f ON a.txgroup_id = f.txgroup_id
915
1265
  LEFT JOIN txgroups g ON f.parent_txgroup = g.txgroup_id
916
- --UNION
917
- --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
918
- --FROM stream_ledger
1266
+ UNION
1267
+ 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
1268
+ FROM stream_ledger
919
1269
  --UNION
920
1270
  --SELECT -1, *
921
1271
  --FROM vesting_ledger
@@ -991,7 +1341,8 @@ def create_monthly_pnl_view() -> None:
991
1341
 
992
1342
 
993
1343
  with db_session:
994
- # create_stream_ledger_view()
1344
+ create_stream_ledger_view()
1345
+ create_txgroup_hierarchy_view()
995
1346
  # create_vesting_ledger_view()
996
1347
  create_general_ledger_view()
997
1348
  create_unsorted_txs_view()
@@ -1051,7 +1402,7 @@ def _validate_integrity_error(
1051
1402
  existing_object.amount,
1052
1403
  )
1053
1404
  except AssertionError:
1054
- logger.warning(
1405
+ logger.debug(
1055
1406
  "slight rounding error in value for TreasuryTx[%s] due to sqlite decimal handling",
1056
1407
  existing_object.treasury_tx_id,
1057
1408
  )
@@ -1076,3 +1427,24 @@ def _validate_integrity_error(
1076
1427
  )
1077
1428
  else None
1078
1429
  )
1430
+
1431
+
1432
+ def _drop_shitcoin_txs() -> None:
1433
+ """
1434
+ Purge any shitcoin txs from the db.
1435
+
1436
+ These should not be frequent, and only occur if a user populated the db before a shitcoin was added to the SHITCOINS mapping.
1437
+ """
1438
+ shitcoins = eth_portfolio.SHITCOINS[CHAINID]
1439
+ with db_session:
1440
+ shitcoin_txs = select(
1441
+ tx for tx in TreasuryTx if tx.token.address.address in shitcoins
1442
+ )
1443
+ if count := shitcoin_txs.count():
1444
+ logger.info(f"Purging {count} shitcoin txs from the database...")
1445
+ for tx in shitcoin_txs:
1446
+ tx.delete()
1447
+ logger.info("Shitcoin tx purge complete.")
1448
+
1449
+
1450
+ _drop_shitcoin_txs()