dao-treasury 0.0.42__cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.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 (51) hide show
  1. bf2b4fe1f86ad2ea158b__mypyc.cpython-312-i386-linux-gnu.so +0 -0
  2. dao_treasury/.grafana/provisioning/dashboards/dashboards.yaml +60 -0
  3. dao_treasury/.grafana/provisioning/dashboards/streams/LlamaPay.json +225 -0
  4. dao_treasury/.grafana/provisioning/dashboards/summary/Monthly.json +107 -0
  5. dao_treasury/.grafana/provisioning/dashboards/transactions/Treasury Transactions.json +387 -0
  6. dao_treasury/.grafana/provisioning/dashboards/treasury/Cashflow (Including Unsorted).json +835 -0
  7. dao_treasury/.grafana/provisioning/dashboards/treasury/Cashflow.json +615 -0
  8. dao_treasury/.grafana/provisioning/dashboards/treasury/Operating Cashflow.json +492 -0
  9. dao_treasury/.grafana/provisioning/dashboards/treasury/Treasury.json +2018 -0
  10. dao_treasury/.grafana/provisioning/datasources/datasources.yaml +17 -0
  11. dao_treasury/ENVIRONMENT_VARIABLES.py +20 -0
  12. dao_treasury/__init__.py +62 -0
  13. dao_treasury/_docker.cpython-312-i386-linux-gnu.so +0 -0
  14. dao_treasury/_docker.py +190 -0
  15. dao_treasury/_nicknames.cpython-312-i386-linux-gnu.so +0 -0
  16. dao_treasury/_nicknames.py +32 -0
  17. dao_treasury/_wallet.cpython-312-i386-linux-gnu.so +0 -0
  18. dao_treasury/_wallet.py +250 -0
  19. dao_treasury/constants.cpython-312-i386-linux-gnu.so +0 -0
  20. dao_treasury/constants.py +34 -0
  21. dao_treasury/db.py +1408 -0
  22. dao_treasury/docker-compose.yaml +41 -0
  23. dao_treasury/main.py +247 -0
  24. dao_treasury/py.typed +0 -0
  25. dao_treasury/sorting/__init__.cpython-312-i386-linux-gnu.so +0 -0
  26. dao_treasury/sorting/__init__.py +295 -0
  27. dao_treasury/sorting/_matchers.cpython-312-i386-linux-gnu.so +0 -0
  28. dao_treasury/sorting/_matchers.py +387 -0
  29. dao_treasury/sorting/_rules.cpython-312-i386-linux-gnu.so +0 -0
  30. dao_treasury/sorting/_rules.py +235 -0
  31. dao_treasury/sorting/factory.cpython-312-i386-linux-gnu.so +0 -0
  32. dao_treasury/sorting/factory.py +299 -0
  33. dao_treasury/sorting/rule.cpython-312-i386-linux-gnu.so +0 -0
  34. dao_treasury/sorting/rule.py +346 -0
  35. dao_treasury/sorting/rules/__init__.cpython-312-i386-linux-gnu.so +0 -0
  36. dao_treasury/sorting/rules/__init__.py +1 -0
  37. dao_treasury/sorting/rules/ignore/__init__.cpython-312-i386-linux-gnu.so +0 -0
  38. dao_treasury/sorting/rules/ignore/__init__.py +1 -0
  39. dao_treasury/sorting/rules/ignore/llamapay.cpython-312-i386-linux-gnu.so +0 -0
  40. dao_treasury/sorting/rules/ignore/llamapay.py +20 -0
  41. dao_treasury/streams/__init__.cpython-312-i386-linux-gnu.so +0 -0
  42. dao_treasury/streams/__init__.py +0 -0
  43. dao_treasury/streams/llamapay.cpython-312-i386-linux-gnu.so +0 -0
  44. dao_treasury/streams/llamapay.py +388 -0
  45. dao_treasury/treasury.py +191 -0
  46. dao_treasury/types.cpython-312-i386-linux-gnu.so +0 -0
  47. dao_treasury/types.py +133 -0
  48. dao_treasury-0.0.42.dist-info/METADATA +119 -0
  49. dao_treasury-0.0.42.dist-info/RECORD +51 -0
  50. dao_treasury-0.0.42.dist-info/WHEEL +7 -0
  51. dao_treasury-0.0.42.dist-info/top_level.txt +2 -0
dao_treasury/db.py ADDED
@@ -0,0 +1,1408 @@
1
+ # mypy: disable-error-code="operator,valid-type,misc"
2
+ """
3
+ Database models and utilities for DAO treasury reporting.
4
+
5
+ This module defines Pony ORM entities for:
6
+
7
+ - Blockchain networks (:class:`Chain`)
8
+ - On-chain addresses (:class:`Address`)
9
+ - ERC-20 tokens and native coin placeholder (:class:`Token`)
10
+ - Hierarchical transaction grouping (:class:`TxGroup`)
11
+ - Treasury transaction records (:class:`TreasuryTx`)
12
+ - Streams and StreamedFunds for streaming payments
13
+
14
+ It also provides helper functions for inserting ledger entries,
15
+ resolving integrity conflicts, caching transaction receipts,
16
+ and creating SQL views for reporting.
17
+ """
18
+
19
+ import typing
20
+ from asyncio import Semaphore
21
+ from decimal import Decimal, InvalidOperation
22
+ from functools import lru_cache
23
+ from logging import getLogger
24
+ from os import path
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Dict, Final, Tuple, Union, final
27
+ from datetime import date, datetime, time, timezone
28
+
29
+ import eth_portfolio
30
+ from a_sync import AsyncThreadPoolExecutor
31
+ from brownie import chain
32
+ from brownie.convert.datatypes import HexString
33
+ from brownie.exceptions import EventLookupError
34
+ from brownie.network.event import EventDict, _EventItem
35
+ from brownie.network.transaction import TransactionReceipt
36
+ from eth_typing import ChecksumAddress, HexAddress, HexStr
37
+ from eth_portfolio.structs import (
38
+ InternalTransfer,
39
+ LedgerEntry,
40
+ TokenTransfer,
41
+ Transaction,
42
+ )
43
+ from pony.orm import (
44
+ Database,
45
+ InterfaceError,
46
+ Optional,
47
+ PrimaryKey,
48
+ Required,
49
+ Set,
50
+ TransactionIntegrityError,
51
+ commit,
52
+ composite_key,
53
+ composite_index,
54
+ db_session,
55
+ select,
56
+ )
57
+ from y import EEE_ADDRESS, Contract, Network, convert, get_block_timestamp_async
58
+ from y._db.decorators import retry_locked
59
+ from y.contracts import _get_code
60
+ from y.exceptions import ContractNotVerified
61
+
62
+ from dao_treasury.constants import CHAINID
63
+ from dao_treasury.types import TxGroupDbid, TxGroupName
64
+
65
+
66
+ SQLITE_DIR = Path(path.expanduser("~")) / ".dao-treasury"
67
+ """Path to the directory in the user's home where the DAO treasury SQLite database is stored."""
68
+
69
+ SQLITE_DIR.mkdir(parents=True, exist_ok=True)
70
+
71
+
72
+ _INSERT_THREAD = AsyncThreadPoolExecutor(1)
73
+ _SORT_THREAD = AsyncThreadPoolExecutor(1)
74
+ _SORT_SEMAPHORE = Semaphore(50)
75
+
76
+ _UTC = timezone.utc
77
+
78
+ db = Database()
79
+
80
+ logger = getLogger("dao_treasury.db")
81
+
82
+
83
+ @final
84
+ class BadToken(ValueError):
85
+ """Raised when a token contract returns invalid metadata.
86
+
87
+ This exception is thrown if the token name or symbol is empty
88
+ or cannot be decoded.
89
+
90
+ Examples:
91
+ >>> raise BadToken("symbol for 0x0 is ''")
92
+ """
93
+
94
+
95
+ # makes type checking work, see below for info:
96
+ # https://pypi.org/project/pony-stubs/
97
+ DbEntity = db.Entity
98
+
99
+
100
+ @final
101
+ class Chain(DbEntity):
102
+ """Pony ORM entity representing a blockchain network.
103
+
104
+ Stores human-readable network names and numeric chain IDs for reporting.
105
+
106
+ Examples:
107
+ >>> Chain.get_dbid(1) # Ethereum Mainnet
108
+ 1
109
+
110
+ See Also:
111
+ :meth:`get_or_insert`
112
+ """
113
+
114
+ _table_ = "chains"
115
+
116
+ chain_dbid = PrimaryKey(int, auto=True)
117
+ """Auto-incremented primary key for the chains table."""
118
+
119
+ chain_name = Required(str, unique=True)
120
+ """Name of the blockchain network, e.g., 'Mainnet', 'Polygon'."""
121
+
122
+ chainid = Required(int, unique=True)
123
+ """Numeric chain ID matching the connected RPC via :data:`~y.constants.CHAINID`."""
124
+
125
+ addresses = Set("Address", reverse="chain", lazy=True)
126
+ """Relationship to address records on this chain."""
127
+
128
+ tokens = Set("Token", reverse="chain", lazy=True)
129
+ """Relationship to token records on this chain."""
130
+
131
+ treasury_txs = Set("TreasuryTx", lazy=True)
132
+ """Relationship to treasury transactions on this chain."""
133
+
134
+ @staticmethod
135
+ @lru_cache(maxsize=None)
136
+ def get_dbid(chainid: int = CHAINID) -> int:
137
+ """Get or create the record for `chainid` and return its database ID.
138
+
139
+ Args:
140
+ chainid: Numeric chain identifier (default uses active RPC via :data:`~y.constants.CHAINID`).
141
+
142
+ Examples:
143
+ >>> Chain.get_dbid(1)
144
+ 1
145
+ """
146
+ with db_session:
147
+ return Chain.get_or_insert(chainid).chain_dbid # type: ignore [no-any-return]
148
+
149
+ @staticmethod
150
+ def get_or_insert(chainid: int) -> "Chain":
151
+ """Insert a new chain record if it does not exist.
152
+
153
+ Args:
154
+ chainid: Numeric chain identifier.
155
+
156
+ Examples:
157
+ >>> chain = Chain.get_or_insert(1)
158
+ >>> chain.chain_name
159
+ 'Mainnet'
160
+ """
161
+ entity = Chain.get(chainid=chainid) or Chain(
162
+ chain_name=Network.name(chainid),
163
+ chainid=chainid,
164
+ # TODO: either remove this or implement it when the dash pieces are together
165
+ # victoria_metrics_label=Network.label(chainid),
166
+ )
167
+ commit()
168
+ return entity
169
+
170
+
171
+ @final
172
+ class Address(DbEntity):
173
+ """Pony ORM entity representing an on-chain address.
174
+
175
+ Records both contract and externally owned addresses for tracing funds.
176
+
177
+ Examples:
178
+ >>> Address.get_dbid("0x0000000000000000000000000000000000000000")
179
+ 1
180
+
181
+ See Also:
182
+ :meth:`get_or_insert`
183
+ """
184
+
185
+ _table_ = "addresses"
186
+
187
+ address_id = PrimaryKey(int, auto=True)
188
+ """Auto-incremented primary key for the addresses table."""
189
+
190
+ chain = Required(Chain, reverse="addresses", lazy=True)
191
+ """Reference to the chain on which this address resides."""
192
+
193
+ address = Required(str, index=True)
194
+ """Checksum string of the on-chain address."""
195
+
196
+ nickname = Optional(str)
197
+ """Optional human-readable label (e.g., contract name or token name)."""
198
+
199
+ is_contract = Required(bool, index=True, lazy=True)
200
+ """Flag indicating whether the address is a smart contract."""
201
+
202
+ composite_key(address, chain)
203
+ composite_index(is_contract, chain)
204
+
205
+ if TYPE_CHECKING:
206
+ token: Optional["Token"]
207
+ treasury_tx_from: Set["TreasuryTx"]
208
+ treasury_tx_to: Set["TreasuryTx"]
209
+
210
+ token = Optional("Token", index=True, lazy=True)
211
+ """Optional back-reference to a Token if this address is one."""
212
+ # partners_tx = Set('PartnerHarvestEvent', reverse='wrapper', lazy=True)
213
+
214
+ treasury_tx_from = Set("TreasuryTx", reverse="from_address", lazy=True)
215
+ """Inverse relation for transactions sent from this address."""
216
+
217
+ treasury_tx_to = Set("TreasuryTx", reverse="to_address", lazy=True)
218
+ """Inverse relation for transactions sent to this address."""
219
+
220
+ streams_from = Set("Stream", reverse="from_address", lazy=True)
221
+ streams_to = Set("Stream", reverse="to_address", lazy=True)
222
+ streams = Set("Stream", reverse="contract", lazy=True)
223
+ # vesting_escrows = Set("VestingEscrow", reverse="address", lazy=True)
224
+ # vests_received = Set("VestingEscrow", reverse="recipient", lazy=True)
225
+ # vests_funded = Set("VestingEscrow", reverse="funder", lazy=True)
226
+
227
+ def __eq__(self, other: Union["Address", ChecksumAddress, "Token"]) -> bool: # type: ignore [override]
228
+ if isinstance(other, str):
229
+ return CHAINID == self.chain.chainid and other == self.address
230
+ elif isinstance(other, Token):
231
+ return self.address_id == other.address.address_id
232
+ return super().__eq__(other)
233
+
234
+ __hash__ = DbEntity.__hash__
235
+
236
+ @property
237
+ def contract(self) -> Contract:
238
+ return Contract(self.address)
239
+
240
+ @staticmethod
241
+ @lru_cache(maxsize=None)
242
+ def get_dbid(address: HexAddress) -> int:
243
+ """Get the DB ID for an address, inserting if necessary.
244
+
245
+ Args:
246
+ address: Hex string of the address (any case, any prefix).
247
+
248
+ Examples:
249
+ >>> Address.get_dbid("0x0000000000000000000000000000000000000000")
250
+ 1
251
+ """
252
+ with db_session:
253
+ return Address.get_or_insert(address).address_id # type: ignore [no-any-return]
254
+
255
+ @staticmethod
256
+ def get_or_insert(address: HexAddress) -> "Address":
257
+ """Insert or fetch an :class:`~dao_treasury.db.Address` for `address`.
258
+
259
+ If the address has on-chain code, attempts to label it using
260
+ the verified contract name or fallback label.
261
+
262
+ Args:
263
+ address: Hex address string.
264
+
265
+ Examples:
266
+ >>> addr = Address.get_or_insert("0x0000000000000000000000000000000000000000")
267
+ >>> addr.is_contract
268
+ False
269
+ """
270
+ checksum_address = convert.to_address(address)
271
+ chain_dbid = Chain.get_dbid()
272
+
273
+ if entity := Address.get(chain=chain_dbid, address=checksum_address):
274
+ return entity # type: ignore [no-any-return]
275
+
276
+ if _get_code(checksum_address, None).hex().removeprefix("0x"):
277
+ try:
278
+ nickname = (
279
+ f"Contract: {Contract(checksum_address)._build['contractName']}"
280
+ )
281
+ except ContractNotVerified:
282
+ nickname = f"Non-Verified Contract: {checksum_address}"
283
+
284
+ entity = Address(
285
+ chain=chain_dbid,
286
+ address=checksum_address,
287
+ nickname=nickname,
288
+ is_contract=True,
289
+ )
290
+
291
+ else:
292
+
293
+ entity = Address(
294
+ chain=chain_dbid,
295
+ address=checksum_address,
296
+ is_contract=False,
297
+ )
298
+
299
+ commit()
300
+ return entity # type: ignore [no-any-return]
301
+
302
+ @staticmethod
303
+ def set_nickname(address: HexAddress, nickname: str) -> None:
304
+ if not nickname:
305
+ raise ValueError("You must provide an actual string")
306
+ with db_session:
307
+ entity = Address.get_or_insert(address)
308
+ if entity.nickname == nickname:
309
+ return
310
+ if entity.nickname:
311
+ old = entity.nickname
312
+ entity.nickname = nickname
313
+ commit()
314
+ logger.info(
315
+ "%s nickname changed from %s to %s", entity.address, old, nickname
316
+ )
317
+ else:
318
+ entity.nickname = nickname
319
+ commit()
320
+ logger.info("%s nickname set to %s", entity.address, nickname)
321
+
322
+ @staticmethod
323
+ def set_nicknames(nicknames: Dict[HexAddress, str]) -> None:
324
+ with db_session:
325
+ for address, nickname in nicknames.items():
326
+ Address.set_nickname(address, nickname)
327
+
328
+
329
+ UNI_V3_POS: Final = {
330
+ Network.Mainnet: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
331
+ }.get(CHAINID, "not on this chain")
332
+
333
+
334
+ def _hex_to_string(h: HexString) -> str:
335
+ """Decode a padded HexString to UTF-8, trimming trailing zero bytes.
336
+
337
+ Args:
338
+ h: The HexString instance from an ERC-20 contract.
339
+
340
+ Examples:
341
+ >>> _hex_to_string(HexString(b'0x5465737400', 'bytes32'))
342
+ 'Test'
343
+ """
344
+ h = h.hex().rstrip("0")
345
+ if len(h) % 2 != 0:
346
+ h += "0"
347
+ return bytes.fromhex(h).decode("utf-8")
348
+
349
+
350
+ @final
351
+ class Token(DbEntity):
352
+ """Pony ORM entity representing an ERC-20 token or native coin placeholder.
353
+
354
+ Stores symbol, name, and decimals for value scaling.
355
+
356
+ Examples:
357
+ >>> Token.get_dbid("0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE")
358
+ 1
359
+ >>> tok = Token.get_or_insert("0x6B175474E89094C44Da98b954EedeAC495271d0F")
360
+ >>> tok.symbol
361
+ 'DAI'
362
+
363
+ See Also:
364
+ :meth:`scale_value`
365
+ """
366
+
367
+ _table_ = "tokens"
368
+
369
+ token_id = PrimaryKey(int, auto=True)
370
+ """Auto-incremented primary key for the tokens table."""
371
+
372
+ chain = Required(Chain, index=True, lazy=True)
373
+ """Foreign key linking to :class:`~dao_treasury.db.Chain`."""
374
+
375
+ symbol = Required(str, index=True, lazy=True)
376
+ """Short ticker symbol for the token."""
377
+
378
+ name = Required(str, lazy=True)
379
+ """Full human-readable name of the token."""
380
+
381
+ decimals = Required(int, lazy=True)
382
+ """Number of decimals used for value scaling."""
383
+
384
+ if TYPE_CHECKING:
385
+ treasury_tx: Set["TreasuryTx"]
386
+
387
+ treasury_tx = Set("TreasuryTx", reverse="token", lazy=True)
388
+ """Inverse relation for treasury transactions involving this token."""
389
+ # partner_harvest_event = Set('PartnerHarvestEvent', reverse="vault", lazy=True)
390
+
391
+ address = Required(Address, column="address_id")
392
+ """Foreign key to the address record for this token contract."""
393
+
394
+ streams = Set("Stream", reverse="token", lazy=True)
395
+ # vesting_escrows = Set("VestingEscrow", reverse="token", lazy=True)
396
+
397
+ def __eq__(self, other: Union["Token", Address, ChecksumAddress]) -> bool: # type: ignore [override]
398
+ if isinstance(other, str):
399
+ return self.address == other
400
+ elif isinstance(other, Address):
401
+ return self.address.address_id == other.address_id
402
+ return super().__eq__(other)
403
+
404
+ __hash__ = DbEntity.__hash__
405
+
406
+ @property
407
+ def contract(self) -> Contract:
408
+ return Contract(self.address.address)
409
+
410
+ @property
411
+ def scale(self) -> int:
412
+ """Base for division according to `decimals`, e.g., `10**decimals`.
413
+
414
+ Examples:
415
+ >>> t = Token.get_or_insert("0x...")
416
+ >>> t.scale
417
+ 1000000000000000000
418
+ """
419
+ return 10**self.decimals # type: ignore [no-any-return]
420
+
421
+ def scale_value(self, value: int) -> Decimal:
422
+ """Convert an integer token amount into a Decimal accounting for `decimals`.
423
+
424
+ Args:
425
+ value: Raw integer on-chain amount.
426
+
427
+ Examples:
428
+ >>> t = Token.get_or_insert("0x...")
429
+ >>> t.scale_value(1500000000000000000)
430
+ Decimal('1.5')
431
+ """
432
+ return Decimal(value) / self.scale
433
+
434
+ @staticmethod
435
+ @lru_cache(maxsize=None)
436
+ def get_dbid(address: HexAddress) -> int:
437
+ """Get or insert a `Token` record and return its database ID.
438
+
439
+ Args:
440
+ address: Token contract address or native coin placeholder.
441
+
442
+ Examples:
443
+ >>> Token.get_dbid("0x6B175474E89094C44Da98b954EedeAC495271d0F")
444
+ 2
445
+ """
446
+ with db_session:
447
+ return Token.get_or_insert(address).token_id # type: ignore [no-any-return]
448
+
449
+ @staticmethod
450
+ def get_or_insert(address: HexAddress) -> "Token":
451
+ """Insert or fetch a token record from the chain, resolving metadata on-chain.
452
+
453
+ Args:
454
+ address: ERC-20 contract address or native coin placeholder.
455
+
456
+ Examples:
457
+ >>> Token.get_or_insert("0x6B175474E89094C44Da98b954EedeAC495271d0F")
458
+ <Token ...>
459
+ """
460
+ address_entity = Address.get_or_insert(address)
461
+ if token := Token.get(address=address_entity):
462
+ return token # type: ignore [no-any-return]
463
+
464
+ address = address_entity.address
465
+ if address == EEE_ADDRESS:
466
+ name, symbol = {Network.Mainnet: ("Ethereum", "ETH")}[chain.id]
467
+ decimals = 18
468
+ else:
469
+ # TODO: use erc20 class from async context before entering this func
470
+ contract = Contract(address)
471
+ try:
472
+ name = contract.name()
473
+ except AttributeError:
474
+ name = "(Unknown)"
475
+ try:
476
+ symbol = contract.symbol()
477
+ except AttributeError:
478
+ symbol = "(Unknown)"
479
+ try:
480
+ decimals = contract.decimals()
481
+ except AttributeError:
482
+ decimals = 0
483
+
484
+ # MKR contract returns name and symbol as bytes32 which is converted to a brownie HexString
485
+ # try to decode it
486
+ if isinstance(name, HexString):
487
+ name = _hex_to_string(name)
488
+ if isinstance(symbol, HexString):
489
+ symbol = _hex_to_string(symbol)
490
+
491
+ if not name:
492
+ raise BadToken(f"name for {address} is {name}")
493
+
494
+ if not symbol:
495
+ raise BadToken(f"symbol for {address} is {symbol}")
496
+
497
+ if address == UNI_V3_POS or decimals is None:
498
+ decimals = 0
499
+
500
+ # update address nickname for token
501
+ if address_entity.nickname is None or address_entity.nickname.startswith(
502
+ "Contract: "
503
+ ):
504
+ # Don't overwrite any intentionally set nicknames, if applicable
505
+ address_entity.nickname = f"Token: {name}"
506
+
507
+ token = Token(
508
+ chain=Chain.get_dbid(),
509
+ address=address_entity.address_id,
510
+ symbol=symbol,
511
+ name=name,
512
+ decimals=decimals,
513
+ )
514
+ commit()
515
+ return token # type: ignore [no-any-return]
516
+
517
+
518
+ class TxGroup(DbEntity):
519
+ """Pony ORM entity for hierarchical transaction groups.
520
+
521
+ Used to categorize treasury transactions into nested buckets.
522
+
523
+ Examples:
524
+ >>> gid = TxGroup.get_dbid("Revenue")
525
+ >>> group = TxGroup.get_or_insert("Revenue", None)
526
+ >>> group.full_string
527
+ 'Revenue'
528
+ """
529
+
530
+ _table_ = "txgroups"
531
+
532
+ txgroup_id = PrimaryKey(int, auto=True)
533
+ """Auto-incremented primary key for transaction groups."""
534
+
535
+ name = Required(str)
536
+ """Name of the grouping category, e.g., 'Revenue', 'Expenses'."""
537
+
538
+ treasury_tx = Set("TreasuryTx", reverse="txgroup", lazy=True)
539
+ """Inverse relation for treasury transactions assigned to this group."""
540
+
541
+ parent_txgroup = Optional("TxGroup", reverse="child_txgroups")
542
+ """Optional reference to a parent group for nesting."""
543
+
544
+ composite_key(name, parent_txgroup)
545
+
546
+ child_txgroups = Set("TxGroup", reverse="parent_txgroup", lazy=True)
547
+ """Set of nested child groups."""
548
+
549
+ streams = Set("Stream", reverse="txgroup", lazy=True)
550
+
551
+ # TODO: implement this
552
+ # vesting_escrows = Set("VestingEscrow", reverse="txgroup", lazy=True)
553
+
554
+ @property
555
+ def fullname(self) -> str:
556
+ """Return the colon-delimited path from root to this group.
557
+
558
+ Examples:
559
+ >>> root = TxGroup.get_or_insert("Revenue", None)
560
+ >>> child = TxGroup.get_or_insert("Interest", root)
561
+ >>> child.full_string
562
+ 'Revenue:Interest'
563
+ """
564
+ t = self
565
+ retval = t.name
566
+ while t.parent_txgroup:
567
+ t = t.parent_txgroup
568
+ retval = f"{t.name}:{retval}"
569
+ return retval
570
+
571
+ @property
572
+ def top_txgroup(self) -> "TxGroup":
573
+ """Get the top-level ancestor in this group’s hierarchy."""
574
+ return self.parent_txgroup.top_txgroup if self.parent_txgroup else self
575
+
576
+ @staticmethod
577
+ @lru_cache(maxsize=None)
578
+ def get_dbid(
579
+ name: TxGroupName, parent: typing.Optional["TxGroup"] = None
580
+ ) -> TxGroupDbid:
581
+ """Get or insert a transaction group and return its database ID.
582
+
583
+ Args:
584
+ name: Category name.
585
+ parent: Optional parent :class:`~dao_treasury.db.TxGroup`.
586
+
587
+ Examples:
588
+ >>> TxGroup.get_dbid("Expenses", None)
589
+ 3
590
+ """
591
+ with db_session:
592
+ return TxGroupDbid(TxGroup.get_or_insert(name, parent).txgroup_id)
593
+
594
+ @staticmethod
595
+ @lru_cache(maxsize=None)
596
+ def get_fullname(dbid: TxGroupDbid) -> TxGroupName:
597
+ with db_session:
598
+ if txgroup := TxGroup.get(txgroup_id=dbid):
599
+ return txgroup.fullname
600
+ raise ValueError(f"TxGroup[{dbid}] not found")
601
+
602
+ @staticmethod
603
+ def get_or_insert(
604
+ name: TxGroupName, parent: typing.Optional["TxGroup"]
605
+ ) -> "TxGroup":
606
+ """Insert or fetch a transaction group.
607
+
608
+ Args:
609
+ name: Category name.
610
+ parent: Optional parent group.
611
+
612
+ Examples:
613
+ >>> TxGroup.get_or_insert("Expenses", None).name
614
+ 'Expenses'
615
+ """
616
+ if txgroup := TxGroup.get(name=name, parent_txgroup=parent):
617
+ return txgroup # type: ignore [no-any-return]
618
+ txgroup = TxGroup(name=name, parent_txgroup=parent)
619
+ try:
620
+ commit()
621
+ except TransactionIntegrityError as e:
622
+ if txgroup := TxGroup.get(name=name, parent_txgroup=parent):
623
+ return txgroup # type: ignore [no-any-return]
624
+ raise Exception(e, name, parent) from e
625
+ return txgroup # type: ignore [no-any-return]
626
+
627
+
628
+ @lru_cache(500)
629
+ def get_transaction(txhash: str) -> TransactionReceipt:
630
+ """Fetch and cache a transaction receipt from the connected chain.
631
+
632
+ Wraps :meth:`brownie.network.chain.Chain.get_transaction`.
633
+
634
+ Args:
635
+ txhash: Hex string of the transaction hash.
636
+
637
+ Examples:
638
+ >>> get_transaction("0xabcde...")
639
+ <Transaction '0xabcde...'>
640
+ """
641
+ return chain.get_transaction(txhash)
642
+
643
+
644
+ class TreasuryTx(DbEntity):
645
+ """Pony ORM entity for on-chain treasury transactions.
646
+
647
+ Represents individual token or native transfers with pricing, grouping, and gas data.
648
+
649
+ Examples:
650
+ >>> # After inserting, fetch sorted records
651
+ >>> with db_session:
652
+ ... txs = TreasuryTx.select(lambda tx: tx.txgroup == TxGroup.get_dbid("Revenue"))
653
+ ... for tx in txs:
654
+ ... print(tx.hash, tx.value_usd)
655
+ """
656
+
657
+ _table_ = "treasury_txs"
658
+
659
+ treasury_tx_id = PrimaryKey(int, auto=True)
660
+ """Auto-incremented primary key for treasury transactions."""
661
+
662
+ chain = Required(Chain, index=True)
663
+ """Foreign key to the network where the transaction occurred."""
664
+
665
+ timestamp = Required(int, index=True)
666
+ """Block timestamp as Unix epoch seconds."""
667
+
668
+ block = Required(int, index=True)
669
+ """Block number of the transaction."""
670
+
671
+ hash = Required(str, index=True)
672
+ """Hex string of the transaction hash."""
673
+
674
+ log_index = Optional(int)
675
+ """Log index within the block (None for native transfers)."""
676
+
677
+ composite_key(hash, log_index)
678
+
679
+ token = Required(Token, reverse="treasury_tx", column="token_id", index=True)
680
+ """Foreign key to the token record used in the transfer."""
681
+
682
+ from_address = Optional(
683
+ Address, reverse="treasury_tx_from", column="from", index=True
684
+ )
685
+ """Foreign key to sender address record."""
686
+
687
+ to_address = Optional(Address, reverse="treasury_tx_to", column="to", index=True)
688
+ """Foreign key to recipient address record."""
689
+
690
+ amount = Required(Decimal, 38, 18)
691
+ """On-chain transfer amount as a Decimal with fixed precision."""
692
+
693
+ price = Optional(Decimal, 38, 18)
694
+ """Token price at the time of transfer (if available)."""
695
+
696
+ value_usd = Optional(Decimal, 38, 18)
697
+ """USD value of the transfer, computed as `amount * price`."""
698
+
699
+ gas_used = Optional(Decimal, 38, 1)
700
+ """Gas units consumed by this transaction (native transfers only)."""
701
+
702
+ gas_price = Optional(Decimal, 38, 1)
703
+ """Gas price paid, in native token units (native transfers only)."""
704
+
705
+ txgroup = Required(
706
+ "TxGroup", reverse="treasury_tx", column="txgroup_id", index=True
707
+ )
708
+ """Foreign key to the categorization group."""
709
+
710
+ composite_index(chain, txgroup)
711
+
712
+ @property
713
+ def to_nickname(self) -> typing.Optional[str]:
714
+ """Human-readable label for the recipient address, if any."""
715
+ if to_address := self.to_address:
716
+ return to_address.nickname or to_address.address
717
+ return None
718
+
719
+ @property
720
+ def from_nickname(self) -> str:
721
+ """Human-readable label for the sender address."""
722
+ return self.from_address.nickname or self.from_address.address # type: ignore [union-attr]
723
+
724
+ @property
725
+ def symbol(self) -> str:
726
+ """Ticker symbol for the transferred token."""
727
+ return self.token.symbol # type: ignore [no-any-return]
728
+
729
+ @property
730
+ def events(self) -> EventDict:
731
+ """Decoded event logs for this transaction."""
732
+ return self._transaction.events
733
+
734
+ def get_events(self, event_name: str) -> _EventItem:
735
+ try:
736
+ return self.events[event_name]
737
+ except EventLookupError:
738
+ pass
739
+ except KeyError as e:
740
+ # This happens sometimes due to a busted abi and hopefully shouldnt impact you
741
+ if str(e) != "'components'":
742
+ raise
743
+ return _EventItem(event_name, None, [], ())
744
+
745
+ @property
746
+ def _transaction(self) -> TransactionReceipt:
747
+ """Cached transaction receipt object."""
748
+ return get_transaction(self.hash)
749
+
750
+ @staticmethod
751
+ async def insert(entry: LedgerEntry) -> None:
752
+ """Asynchronously insert and sort a ledger entry.
753
+
754
+ Converts a :class:`~eth_portfolio.structs.LedgerEntry` into a
755
+ :class:`~dao_treasury.db.TreasuryTx` record, then applies advanced sorting.
756
+
757
+ Args:
758
+ entry: A ledger entry representing a token or internal transfer.
759
+
760
+ Examples:
761
+ >>> import asyncio, eth_portfolio.structs as s
762
+ >>> asyncio.run(TreasuryTx.insert(s.TokenTransfer(...)))
763
+ See Also:
764
+ :meth:`__insert`
765
+ """
766
+ timestamp = int(await get_block_timestamp_async(entry.block_number))
767
+ if txid := await _INSERT_THREAD.run(TreasuryTx.__insert, entry, timestamp):
768
+ async with _SORT_SEMAPHORE:
769
+ from dao_treasury.sorting import sort_advanced
770
+
771
+ try:
772
+ await sort_advanced(TreasuryTx[txid])
773
+ except Exception as e:
774
+ e.args = *e.args, entry
775
+ raise
776
+
777
+ async def _set_txgroup(self, txgroup_dbid: TxGroupDbid) -> None:
778
+ await _SORT_THREAD.run(
779
+ TreasuryTx.__set_txgroup, self.treasury_tx_id, txgroup_dbid
780
+ )
781
+
782
+ @staticmethod
783
+ def __insert(entry: LedgerEntry, ts: int) -> typing.Optional[int]:
784
+ """Synchronously insert a ledger entry record into the database.
785
+
786
+ Handles both :class:`TokenTransfer` and other ledger entry types,
787
+ populates pricing fields, and resolves grouping via basic sorting.
788
+
789
+ Args:
790
+ entry: Ledger entry to insert.
791
+ ts: Unix timestamp of the block.
792
+
793
+ If a uniqueness conflict arises, delegates to
794
+ :func:`_validate_integrity_error`. Returns the new record ID
795
+ if further advanced sorting is required.
796
+ """
797
+ try:
798
+ with db_session:
799
+ if isinstance(entry, TokenTransfer):
800
+ token = Token.get_dbid(entry.token_address)
801
+ log_index = entry.log_index
802
+ gas, gas_price, gas_used = None, None, None
803
+ else:
804
+ token = Token.get_dbid(EEE_ADDRESS)
805
+ log_index = None
806
+ gas = entry.gas
807
+ gas_used = (
808
+ entry.gas_used if isinstance(entry, InternalTransfer) else None
809
+ )
810
+ gas_price = (
811
+ entry.gas_price if isinstance(entry, Transaction) else None
812
+ )
813
+
814
+ if to_address := entry.to_address:
815
+ to_address = Address.get_dbid(to_address)
816
+ if from_address := entry.from_address:
817
+ from_address = Address.get_dbid(from_address)
818
+
819
+ # TODO: resolve this circ import
820
+ from dao_treasury.sorting import sort_basic
821
+
822
+ txgroup_dbid = sort_basic(entry)
823
+
824
+ entity = TreasuryTx(
825
+ chain=Chain.get_dbid(CHAINID),
826
+ block=entry.block_number,
827
+ timestamp=ts,
828
+ hash=entry.hash.hex(),
829
+ log_index=log_index,
830
+ from_address=from_address,
831
+ to_address=to_address,
832
+ token=token,
833
+ amount=entry.value,
834
+ price=entry.price,
835
+ value_usd=entry.value_usd,
836
+ # TODO: nuke db and add this column
837
+ # gas = gas,
838
+ gas_used=gas_used,
839
+ gas_price=gas_price,
840
+ txgroup=txgroup_dbid,
841
+ )
842
+ # we must commit here or else dbid below will be `None`.
843
+ commit()
844
+ dbid = entity.treasury_tx_id
845
+ except InterfaceError as e:
846
+ raise ValueError(
847
+ e,
848
+ {
849
+ "chain": Chain.get_dbid(CHAINID),
850
+ "block": entry.block_number,
851
+ "timestamp": ts,
852
+ "hash": entry.hash.hex(),
853
+ "log_index": log_index,
854
+ "from_address": from_address,
855
+ "to_address": to_address,
856
+ "token": token,
857
+ "amount": entry.value,
858
+ "price": entry.price,
859
+ "value_usd": entry.value_usd,
860
+ # TODO: nuke db and add this column
861
+ # gas = gas,
862
+ "gas_used": gas_used,
863
+ "gas_price": gas_price,
864
+ "txgroup": txgroup_dbid,
865
+ },
866
+ ) from e
867
+ except InvalidOperation as e:
868
+ with db_session:
869
+ from_address_entity = Address[from_address]
870
+ to_address_entity = Address[to_address]
871
+ token_entity = Token[token]
872
+ logger.error(e)
873
+ logger.error(
874
+ {
875
+ "chain": Chain.get_dbid(CHAINID),
876
+ "block": entry.block_number,
877
+ "timestamp": ts,
878
+ "hash": entry.hash.hex(),
879
+ "log_index": log_index,
880
+ "from_address": {
881
+ "dbid": from_address,
882
+ "address": from_address_entity.address,
883
+ "nickname": from_address_entity.nickname,
884
+ },
885
+ "to_address": {
886
+ "dbid": to_address,
887
+ "address": to_address_entity.address,
888
+ "nickname": to_address_entity.nickname,
889
+ },
890
+ "token": {
891
+ "dbid": token,
892
+ "address": token_entity.address.address,
893
+ "name": token_entity.name,
894
+ "symbol": token_entity.symbol,
895
+ "decimals": token_entity.decimals,
896
+ },
897
+ "amount": entry.value,
898
+ "price": entry.price,
899
+ "value_usd": entry.value_usd,
900
+ # TODO: nuke db and add this column
901
+ # gas = gas,
902
+ "gas_used": gas_used,
903
+ "gas_price": gas_price,
904
+ "txgroup": {
905
+ "dbid": txgroup_dbid,
906
+ "fullname": TxGroup[txgroup_dbid].fullname,
907
+ },
908
+ }
909
+ )
910
+ return None
911
+ except TransactionIntegrityError as e:
912
+ return _validate_integrity_error(entry, log_index)
913
+ except Exception as e:
914
+ e.args = *e.args, entry
915
+ raise
916
+ else:
917
+ if txgroup_dbid not in (
918
+ must_sort_inbound_txgroup_dbid,
919
+ must_sort_outbound_txgroup_dbid,
920
+ ):
921
+ logger.info(
922
+ "Sorted %s to %s", entry, TxGroup.get_fullname(txgroup_dbid)
923
+ )
924
+ return None
925
+ return dbid # type: ignore [no-any-return]
926
+
927
+ @staticmethod
928
+ @retry_locked
929
+ def __set_txgroup(treasury_tx_dbid: int, txgroup_dbid: TxGroupDbid) -> None:
930
+ with db_session:
931
+ TreasuryTx[treasury_tx_dbid].txgroup = txgroup_dbid
932
+ commit()
933
+
934
+
935
+ _stream_metadata_cache: Final[Dict[HexStr, Tuple[ChecksumAddress, date]]] = {}
936
+
937
+
938
+ class Stream(DbEntity):
939
+ _table_ = "streams"
940
+ stream_id = PrimaryKey(str)
941
+
942
+ contract = Required("Address", reverse="streams")
943
+ start_block = Required(int)
944
+ end_block = Optional(int)
945
+ token = Required("Token", reverse="streams", index=True)
946
+ from_address = Required("Address", reverse="streams_from")
947
+ to_address = Required("Address", reverse="streams_to")
948
+ reason = Optional(str)
949
+ amount_per_second = Required(Decimal, 38, 1)
950
+ status = Required(str, default="Active")
951
+ txgroup = Optional("TxGroup", reverse="streams")
952
+
953
+ streamed_funds = Set("StreamedFunds", lazy=True)
954
+
955
+ scale = 10**20
956
+
957
+ @property
958
+ def is_alive(self) -> bool:
959
+ if self.end_block is None:
960
+ assert self.status in ["Active", "Paused"]
961
+ return self.status == "Active"
962
+ assert self.status == "Stopped"
963
+ return False
964
+
965
+ @property
966
+ def amount_per_minute(self) -> int:
967
+ return self.amount_per_second * 60
968
+
969
+ @property
970
+ def amount_per_hour(self) -> int:
971
+ return self.amount_per_minute * 60
972
+
973
+ @property
974
+ def amount_per_day(self) -> int:
975
+ return self.amount_per_hour * 24
976
+
977
+ @staticmethod
978
+ def check_closed(stream_id: HexStr) -> bool:
979
+ with db_session:
980
+ return any(sf.is_last_day for sf in Stream[stream_id].streamed_funds)
981
+
982
+ @staticmethod
983
+ def _get_start_and_end(stream_dbid: HexStr) -> Tuple[datetime, datetime]:
984
+ with db_session:
985
+ stream = Stream[stream_dbid]
986
+ start_date, end = stream.start_date, datetime.now(_UTC)
987
+ # convert start to datetime
988
+ start = datetime.combine(start_date, time(tzinfo=_UTC), tzinfo=_UTC)
989
+ if stream.end_block:
990
+ end = datetime.fromtimestamp(chain[stream.end_block].timestamp, tz=_UTC)
991
+ return start, end
992
+
993
+ def stop_stream(self, block: int) -> None:
994
+ self.end_block = block
995
+ self.status = "Stopped"
996
+
997
+ def pause(self) -> None:
998
+ self.status = "Paused"
999
+
1000
+ @staticmethod
1001
+ def _get_token_and_start_date(stream_id: HexStr) -> Tuple[ChecksumAddress, date]:
1002
+ try:
1003
+ return _stream_metadata_cache[stream_id]
1004
+ except KeyError:
1005
+ with db_session:
1006
+ stream = Stream[stream_id]
1007
+ token = stream.token.address.address
1008
+ start_date = stream.start_date
1009
+ _stream_metadata_cache[stream_id] = token, start_date
1010
+ return token, start_date
1011
+
1012
+ @property
1013
+ def stream_contract(self) -> Contract:
1014
+ return Contract(self.contract.address)
1015
+
1016
+ @property
1017
+ def start_date(self) -> date:
1018
+ return datetime.fromtimestamp(chain[self.start_block].timestamp).date()
1019
+
1020
+ async def amount_withdrawable(self, block: int) -> int:
1021
+ return await self.stream_contract.withdrawable.coroutine(
1022
+ self.from_address.address,
1023
+ self.to_address.address,
1024
+ int(self.amount_per_second),
1025
+ block_identifier=block,
1026
+ )
1027
+
1028
+ def print(self) -> None:
1029
+ symbol = self.token.symbol
1030
+ print(f"{symbol} per second: {self.amount_per_second / self.scale}")
1031
+ print(f"{symbol} per day: {self.amount_per_day / self.scale}")
1032
+
1033
+
1034
+ class StreamedFunds(DbEntity):
1035
+ """Each object represents one calendar day of tokens streamed for a particular stream."""
1036
+
1037
+ _table_ = "streamed_funds"
1038
+
1039
+ date = Required(date)
1040
+ stream = Required(Stream, reverse="streamed_funds")
1041
+ PrimaryKey(stream, date)
1042
+
1043
+ amount = Required(Decimal, 38, 18)
1044
+ price = Required(Decimal, 38, 18)
1045
+ value_usd = Required(Decimal, 38, 18)
1046
+ seconds_active = Required(int)
1047
+ is_last_day = Required(bool)
1048
+
1049
+ @db_session
1050
+ def get_entity(stream_id: str, date: datetime) -> "StreamedFunds":
1051
+ stream = Stream[stream_id]
1052
+ return StreamedFunds.get(date=date, stream=stream)
1053
+
1054
+ @classmethod
1055
+ @db_session
1056
+ def create_entity(
1057
+ cls,
1058
+ stream_id: str,
1059
+ date: datetime,
1060
+ price: Decimal,
1061
+ seconds_active: int,
1062
+ is_last_day: bool,
1063
+ ) -> "StreamedFunds":
1064
+ stream = Stream[stream_id]
1065
+ amount_streamed_today = round(
1066
+ stream.amount_per_second * seconds_active / stream.scale, 18
1067
+ )
1068
+ entity = StreamedFunds(
1069
+ date=date,
1070
+ stream=stream,
1071
+ amount=amount_streamed_today,
1072
+ price=round(price, 18),
1073
+ value_usd=round(amount_streamed_today * price, 18),
1074
+ seconds_active=seconds_active,
1075
+ is_last_day=is_last_day,
1076
+ )
1077
+ return entity
1078
+
1079
+
1080
+ db.bind(
1081
+ provider="sqlite", # TODO: let user choose postgres with server connection params
1082
+ filename=str(SQLITE_DIR / "dao-treasury.sqlite"),
1083
+ create_db=True,
1084
+ )
1085
+
1086
+ db.generate_mapping(create_tables=True)
1087
+
1088
+
1089
+ def _set_address_nicknames_for_tokens() -> None:
1090
+ """Set address.nickname for addresses belonging to tokens."""
1091
+ for address in select(a for a in Address if a.token and not a.nickname):
1092
+ address.nickname = f"Token: {address.token.name}"
1093
+ db.commit()
1094
+
1095
+
1096
+ def create_stream_ledger_view() -> None:
1097
+ """Create or replace the SQL view `stream_ledger` for streamed funds reporting.
1098
+
1099
+ This view joins streamed funds, streams, tokens, addresses, and txgroups
1100
+ into a unified ledger of stream transactions.
1101
+
1102
+ Examples:
1103
+ >>> create_stream_ledger_view()
1104
+ """
1105
+ db.execute("""DROP VIEW IF EXISTS stream_ledger;""")
1106
+ db.execute(
1107
+ """
1108
+ create view stream_ledger as
1109
+ SELECT 'Mainnet' as chain_name,
1110
+ cast(strftime('%s', date || ' 00:00:00') as INTEGER) as timestamp,
1111
+ NULL as block,
1112
+ NULL as hash,
1113
+ NULL as log_index,
1114
+ symbol as token,
1115
+ d.address AS "from",
1116
+ d.nickname as from_nickname,
1117
+ e.address AS "to",
1118
+ e.nickname as to_nickname,
1119
+ amount,
1120
+ price,
1121
+ value_usd,
1122
+ txgroup.name as txgroup,
1123
+ parent.name as parent_txgroup,
1124
+ txgroup.txgroup_id
1125
+ FROM streamed_funds a
1126
+ LEFT JOIN streams b ON a.stream = b.stream_id
1127
+ LEFT JOIN tokens c ON b.token = c.token_id
1128
+ LEFT JOIN addresses d ON b.from_address = d.address_id
1129
+ LEFT JOIN addresses e ON b.to_address = e.address_id
1130
+ LEFT JOIN txgroups txgroup ON b.txgroup = txgroup.txgroup_id
1131
+ LEFT JOIN txgroups parent ON txgroup.parent_txgroup = parent.txgroup_id
1132
+ """
1133
+ )
1134
+
1135
+
1136
+ def create_txgroup_hierarchy_view() -> None:
1137
+ """Create or replace the SQL view `txgroup_hierarchy` for recursive txgroup hierarchy.
1138
+
1139
+ This view exposes txgroup_id, top_category, and parent_txgroup for all txgroups,
1140
+ matching the recursive CTE logic used in dashboards.
1141
+ """
1142
+ db.execute("DROP VIEW IF EXISTS txgroup_hierarchy;")
1143
+ db.execute(
1144
+ """
1145
+ CREATE VIEW txgroup_hierarchy AS
1146
+ WITH RECURSIVE group_hierarchy (txgroup_id, top_category, parent_txgroup) AS (
1147
+ SELECT txgroup_id, name AS top_category, parent_txgroup
1148
+ FROM txgroups
1149
+ WHERE parent_txgroup IS NULL
1150
+ UNION ALL
1151
+ SELECT child.txgroup_id, parent.top_category, child.parent_txgroup
1152
+ FROM txgroups AS child
1153
+ JOIN group_hierarchy AS parent
1154
+ ON child.parent_txgroup = parent.txgroup_id
1155
+ )
1156
+ SELECT * FROM group_hierarchy;
1157
+ """
1158
+ )
1159
+
1160
+
1161
+ def create_vesting_ledger_view() -> None:
1162
+ """Create or replace the SQL view `vesting_ledger` for vesting escrow reporting.
1163
+
1164
+ This view joins vested funds, vesting escrows, tokens, chains, addresses,
1165
+ and txgroups to produce a vesting ledger.
1166
+
1167
+ Examples:
1168
+ >>> create_vesting_ledger_view()
1169
+ """
1170
+ db.execute(
1171
+ """
1172
+ DROP VIEW IF EXISTS vesting_ledger;
1173
+ CREATE VIEW vesting_ledger AS
1174
+ SELECT d.chain_name,
1175
+ CAST(date AS timestamp) AS "timestamp",
1176
+ cast(NULL as int) AS block,
1177
+ NULL AS "hash",
1178
+ cast(NULL as int) AS "log_index",
1179
+ c.symbol AS "token",
1180
+ e.address AS "from",
1181
+ e.nickname as from_nickname,
1182
+ f.address AS "to",
1183
+ f.nickname as to_nickname,
1184
+ a.amount,
1185
+ a.price,
1186
+ a.value_usd,
1187
+ g.name as txgroup,
1188
+ h.name AS parent_txgroup,
1189
+ g.txgroup_id
1190
+ FROM vested_funds a
1191
+ LEFT JOIN vesting_escrows b ON a.escrow = b.escrow_id
1192
+ LEFT JOIN tokens c ON b.token = c.token_id
1193
+ LEFT JOIN chains d ON c.chain = d.chain_dbid
1194
+ LEFT JOIN addresses e ON b.address = e.address_id
1195
+ LEFT JOIN addresses f ON b.recipient = f.address_id
1196
+ LEFT JOIN txgroups g ON b.txgroup = g.txgroup_id
1197
+ left JOIN txgroups h ON g.parent_txgroup = h.txgroup_id
1198
+ """
1199
+ )
1200
+
1201
+
1202
+ def create_general_ledger_view() -> None:
1203
+ """Create or replace the SQL view `general_ledger` aggregating all treasury transactions.
1204
+
1205
+ Joins chains, tokens, addresses, and txgroups into a single chronological ledger.
1206
+
1207
+ Examples:
1208
+ >>> create_general_ledger_view()
1209
+ """
1210
+ db.execute("drop VIEW IF EXISTS general_ledger")
1211
+ db.execute(
1212
+ """
1213
+ create VIEW general_ledger as
1214
+ select *
1215
+ from (
1216
+ 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
1217
+ FROM treasury_txs a
1218
+ LEFT JOIN chains b ON a.chain = b.chain_dbid
1219
+ LEFT JOIN tokens c ON a.token_id = c.token_id
1220
+ LEFT JOIN addresses d ON a."from" = d.address_id
1221
+ LEFT JOIN addresses e ON a."to" = e.address_id
1222
+ LEFT JOIN txgroups f ON a.txgroup_id = f.txgroup_id
1223
+ LEFT JOIN txgroups g ON f.parent_txgroup = g.txgroup_id
1224
+ UNION
1225
+ 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
1226
+ FROM stream_ledger
1227
+ --UNION
1228
+ --SELECT -1, *
1229
+ --FROM vesting_ledger
1230
+ ) a
1231
+ ORDER BY timestamp
1232
+ """
1233
+ )
1234
+
1235
+
1236
+ def create_unsorted_txs_view() -> None:
1237
+ """Create or replace the SQL view `unsorted_txs` for pending categorization.
1238
+
1239
+ Filters `general_ledger` for transactions still in 'Categorization Pending'.
1240
+
1241
+ Examples:
1242
+ >>> create_unsorted_txs_view()
1243
+ """
1244
+ db.execute("DROP VIEW IF EXISTS unsorted_txs;")
1245
+ db.execute(
1246
+ """
1247
+ CREATE VIEW unsorted_txs as
1248
+ SELECT *
1249
+ FROM general_ledger
1250
+ WHERE txgroup = 'Categorization Pending'
1251
+ ORDER BY TIMESTAMP desc
1252
+ """
1253
+ )
1254
+
1255
+
1256
+ def create_monthly_pnl_view() -> None:
1257
+ """Create or replace the SQL view `monthly_pnl` summarizing monthly profit and loss.
1258
+
1259
+ Aggregates categorized transactions by month and top-level category.
1260
+
1261
+ Examples:
1262
+ >>> create_monthly_pnl_view()
1263
+ """
1264
+ db.execute("DROP VIEW IF EXISTS monthly_pnl;")
1265
+ sql = """
1266
+ CREATE VIEW monthly_pnl AS
1267
+ WITH categorized AS (
1268
+ SELECT
1269
+ strftime('%Y-%m', datetime(t.timestamp, 'unixepoch')) AS month,
1270
+ CASE
1271
+ WHEN p.name IS NOT NULL THEN p.name
1272
+ ELSE tg.name
1273
+ END AS top_category,
1274
+ --COALESCE(t.value_usd, 0) AS value_usd,
1275
+ --COALESCE(t.gas_used, 0) * COALESCE(t.gas_price, 0) AS gas_cost
1276
+ FROM treasury_txs t
1277
+ JOIN txgroups tg ON t.txgroup = tg.txgroup_id
1278
+ LEFT JOIN txgroups p ON tg.parent_txgroup = p.txgroup_id
1279
+ WHERE tg.name <> 'Ignore'
1280
+ )
1281
+ SELECT
1282
+ month,
1283
+ SUM(CASE WHEN top_category = 'Revenue' THEN value_usd ELSE 0 END) AS revenue,
1284
+ SUM(CASE WHEN top_category = 'Cost of Revenue' THEN value_usd ELSE 0 END) AS cost_of_revenue,
1285
+ SUM(CASE WHEN top_category = 'Expenses' THEN value_usd ELSE 0 END) AS expenses,
1286
+ SUM(CASE WHEN top_category = 'Other Income' THEN value_usd ELSE 0 END) AS other_income,
1287
+ SUM(CASE WHEN top_category = 'Other Expenses' THEN value_usd ELSE 0 END) AS other_expense,
1288
+ (
1289
+ SUM(CASE WHEN top_category = 'Revenue' THEN value_usd ELSE 0 END) -
1290
+ SUM(CASE WHEN top_category = 'Cost of Revenue' THEN value_usd ELSE 0 END) -
1291
+ SUM(CASE WHEN top_category = 'Expenses' THEN value_usd ELSE 0 END) +
1292
+ SUM(CASE WHEN top_category = 'Other Income' THEN value_usd ELSE 0 END) -
1293
+ SUM(CASE WHEN top_category = 'Other Expenses' THEN value_usd ELSE 0 END)
1294
+ ) AS net_profit
1295
+ FROM categorized
1296
+ GROUP BY month;
1297
+ """
1298
+ db.execute(sql)
1299
+
1300
+
1301
+ with db_session:
1302
+ create_stream_ledger_view()
1303
+ create_txgroup_hierarchy_view()
1304
+ # create_vesting_ledger_view()
1305
+ create_general_ledger_view()
1306
+ create_unsorted_txs_view()
1307
+ # create_monthly_pnl_view()
1308
+
1309
+ must_sort_inbound_txgroup_dbid = TxGroup.get_dbid(name="Sort Me (Inbound)")
1310
+ must_sort_outbound_txgroup_dbid = TxGroup.get_dbid(name="Sort Me (Outbound)")
1311
+
1312
+
1313
+ @db_session
1314
+ def _validate_integrity_error(
1315
+ entry: LedgerEntry, log_index: int
1316
+ ) -> typing.Optional[int]:
1317
+ """Validate that an existing TreasuryTx matches an attempted insert on conflict.
1318
+
1319
+ Raises AssertionError if any field deviates from the existing record. Used
1320
+ to resolve :exc:`pony.orm.TransactionIntegrityError`.
1321
+
1322
+ Args:
1323
+ entry: The ledger entry that triggered the conflict.
1324
+ log_index: The log index within the transaction.
1325
+
1326
+ Examples:
1327
+ >>> _validate_integrity_error(entry, 0)
1328
+ """
1329
+ txhash = entry.hash.hex()
1330
+ chain_dbid = Chain.get_dbid()
1331
+ existing_object = TreasuryTx.get(hash=txhash, log_index=log_index, chain=chain_dbid)
1332
+ if existing_object is None:
1333
+ existing_objects = list(
1334
+ TreasuryTx.select(
1335
+ lambda tx: tx.hash == txhash
1336
+ and tx.log_index == log_index
1337
+ and tx.chain == chain_dbid
1338
+ )
1339
+ )
1340
+ raise ValueError(
1341
+ f"unable to `.get` due to multiple entries: {existing_objects}"
1342
+ )
1343
+ if entry.to_address:
1344
+ assert entry.to_address == existing_object.to_address.address, (
1345
+ entry.to_address,
1346
+ existing_object.to_address.address,
1347
+ )
1348
+ else:
1349
+ assert existing_object.to_address is None, (
1350
+ entry.to_address,
1351
+ existing_object.to_address,
1352
+ )
1353
+ assert entry.from_address == existing_object.from_address.address, (
1354
+ entry.from_address,
1355
+ existing_object.from_address.address,
1356
+ )
1357
+ try:
1358
+ assert entry.value in [existing_object.amount, -1 * existing_object.amount], (
1359
+ entry.value,
1360
+ existing_object.amount,
1361
+ )
1362
+ except AssertionError:
1363
+ logger.debug(
1364
+ "slight rounding error in value for TreasuryTx[%s] due to sqlite decimal handling",
1365
+ existing_object.treasury_tx_id,
1366
+ )
1367
+ assert entry.block_number == existing_object.block, (
1368
+ entry.block_number,
1369
+ existing_object.block,
1370
+ )
1371
+ if isinstance(entry, TokenTransfer):
1372
+ assert entry.token_address == existing_object.token.address.address, (
1373
+ entry.token_address,
1374
+ existing_object.token.address.address,
1375
+ )
1376
+ else:
1377
+ assert existing_object.token == EEE_ADDRESS
1378
+ # NOTE All good!
1379
+ return (
1380
+ existing_object.treasury_tx_id
1381
+ if existing_object.txgroup.txgroup_id
1382
+ in (
1383
+ must_sort_inbound_txgroup_dbid,
1384
+ must_sort_outbound_txgroup_dbid,
1385
+ )
1386
+ else None
1387
+ )
1388
+
1389
+
1390
+ def _drop_shitcoin_txs() -> None:
1391
+ """
1392
+ Purge any shitcoin txs from the db.
1393
+
1394
+ These should not be frequent, and only occur if a user populated the db before a shitcoin was added to the SHITCOINS mapping.
1395
+ """
1396
+ shitcoins = eth_portfolio.SHITCOINS[CHAINID]
1397
+ with db_session:
1398
+ shitcoin_txs = select(
1399
+ tx for tx in TreasuryTx if tx.token.address.address in shitcoins
1400
+ )
1401
+ if count := shitcoin_txs.count():
1402
+ logger.info(f"Purging {count} shitcoin txs from the database...")
1403
+ for tx in shitcoin_txs:
1404
+ tx.delete()
1405
+ logger.info("Shitcoin tx purge complete.")
1406
+
1407
+
1408
+ _drop_shitcoin_txs()