doublezero-revenue-distribution 0.0.2__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ *.egg-info/
5
+ dist/
6
+ .pytest_cache/
7
+ uv.lock
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: doublezero-revenue-distribution
3
+ Version: 0.0.2
4
+ Summary: DoubleZero Revenue Distribution SDK
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: base58>=2.1
7
+ Requires-Dist: borsh-incremental
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: solana>=0.35
10
+ Requires-Dist: solders>=0.21
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "doublezero-revenue-distribution"
3
+ version = "0.0.2"
4
+ description = "DoubleZero Revenue Distribution SDK"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "borsh-incremental",
8
+ "solana>=0.35",
9
+ "solders>=0.21",
10
+ "httpx>=0.27",
11
+ "base58>=2.1",
12
+ ]
13
+
14
+ [tool.pytest.ini_options]
15
+ testpaths = ["revdist/tests"]
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["revdist"]
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "pytest>=9.0.2",
27
+ ]
28
+
29
+ [tool.uv.sources]
30
+ borsh-incremental = { path = "../../borsh-incremental/python", editable = true }
@@ -0,0 +1,70 @@
1
+ from revdist.client import Client
2
+ from revdist.config import (
3
+ LEDGER_RPC_URLS,
4
+ ORACLE_URLS,
5
+ PROGRAM_ID,
6
+ SOLANA_RPC_URLS,
7
+ )
8
+ from revdist.oracle import OracleClient, SwapRate
9
+ from revdist.rpc import new_rpc_client
10
+ from revdist.discriminator import (
11
+ DISCRIMINATOR_CONTRIBUTOR_REWARDS,
12
+ DISCRIMINATOR_DISTRIBUTION,
13
+ DISCRIMINATOR_JOURNAL,
14
+ DISCRIMINATOR_PROGRAM_CONFIG,
15
+ DISCRIMINATOR_SOLANA_VALIDATOR_DEPOSIT,
16
+ )
17
+ from revdist.pda import (
18
+ derive_config_pda,
19
+ derive_contributor_rewards_pda,
20
+ derive_distribution_pda,
21
+ derive_journal_pda,
22
+ derive_record_key,
23
+ derive_reward_share_record_key,
24
+ derive_validator_debt_record_key,
25
+ derive_validator_deposit_pda,
26
+ )
27
+ from revdist.state import (
28
+ ComputedSolanaValidatorDebt,
29
+ ComputedSolanaValidatorDebts,
30
+ ContributorRewards,
31
+ Distribution,
32
+ Journal,
33
+ ProgramConfig,
34
+ RewardShare,
35
+ ShapleyOutputStorage,
36
+ SolanaValidatorDeposit,
37
+ )
38
+
39
+ __all__ = [
40
+ "Client",
41
+ "LEDGER_RPC_URLS",
42
+ "ORACLE_URLS",
43
+ "OracleClient",
44
+ "PROGRAM_ID",
45
+ "SOLANA_RPC_URLS",
46
+ "SwapRate",
47
+ "ComputedSolanaValidatorDebt",
48
+ "ComputedSolanaValidatorDebts",
49
+ "ContributorRewards",
50
+ "Distribution",
51
+ "Journal",
52
+ "ProgramConfig",
53
+ "RewardShare",
54
+ "ShapleyOutputStorage",
55
+ "SolanaValidatorDeposit",
56
+ "DISCRIMINATOR_CONTRIBUTOR_REWARDS",
57
+ "DISCRIMINATOR_DISTRIBUTION",
58
+ "DISCRIMINATOR_JOURNAL",
59
+ "DISCRIMINATOR_PROGRAM_CONFIG",
60
+ "DISCRIMINATOR_SOLANA_VALIDATOR_DEPOSIT",
61
+ "derive_config_pda",
62
+ "derive_contributor_rewards_pda",
63
+ "derive_distribution_pda",
64
+ "derive_journal_pda",
65
+ "derive_record_key",
66
+ "derive_reward_share_record_key",
67
+ "derive_validator_debt_record_key",
68
+ "derive_validator_deposit_pda",
69
+ "new_rpc_client",
70
+ ]
@@ -0,0 +1,189 @@
1
+ """RPC client for fetching revenue distribution program accounts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+ from typing import Protocol
7
+
8
+ from solana.rpc.api import Client as SolanaHTTPClient # type: ignore[import-untyped]
9
+
10
+ from revdist.rpc import new_rpc_client
11
+ from solders.pubkey import Pubkey # type: ignore[import-untyped]
12
+ from solders.rpc.responses import GetAccountInfoResp # type: ignore[import-untyped]
13
+
14
+ from revdist.config import LEDGER_RPC_URLS, PROGRAM_ID, SOLANA_RPC_URLS
15
+ from revdist.discriminator import (
16
+ DISCRIMINATOR_CONTRIBUTOR_REWARDS,
17
+ DISCRIMINATOR_DISTRIBUTION,
18
+ DISCRIMINATOR_JOURNAL,
19
+ DISCRIMINATOR_PROGRAM_CONFIG,
20
+ DISCRIMINATOR_SOLANA_VALIDATOR_DEPOSIT,
21
+ )
22
+ from revdist.pda import (
23
+ RECORD_HEADER_SIZE,
24
+ derive_config_pda,
25
+ derive_contributor_rewards_pda,
26
+ derive_distribution_pda,
27
+ derive_journal_pda,
28
+ derive_reward_share_record_key,
29
+ derive_validator_debt_record_key,
30
+ derive_validator_deposit_pda,
31
+ )
32
+ from revdist.state import (
33
+ ComputedSolanaValidatorDebts,
34
+ ContributorRewards,
35
+ Distribution,
36
+ Journal,
37
+ ProgramConfig,
38
+ ShapleyOutputStorage,
39
+ SolanaValidatorDeposit,
40
+ )
41
+
42
+
43
+ class SolanaClient(Protocol):
44
+ def get_account_info(self, pubkey: Pubkey) -> GetAccountInfoResp: ...
45
+
46
+
47
+ class Client:
48
+ """Read-only client for revenue distribution program accounts."""
49
+
50
+ def __init__(
51
+ self,
52
+ solana_rpc: SolanaClient,
53
+ ledger_rpc: SolanaClient,
54
+ program_id: Pubkey,
55
+ ) -> None:
56
+ self._solana_rpc = solana_rpc
57
+ self._ledger_rpc = ledger_rpc
58
+ self._program_id = program_id
59
+
60
+ @classmethod
61
+ def from_env(cls, env: str) -> Client:
62
+ """Create a client configured for the given environment.
63
+
64
+ Args:
65
+ env: Environment name ("mainnet-beta", "testnet", "devnet", "localnet")
66
+ """
67
+ return cls(
68
+ new_rpc_client(SOLANA_RPC_URLS[env]),
69
+ new_rpc_client(LEDGER_RPC_URLS[env]),
70
+ Pubkey.from_string(PROGRAM_ID),
71
+ )
72
+
73
+ @classmethod
74
+ def mainnet_beta(cls) -> Client:
75
+ """Create a client configured for mainnet-beta."""
76
+ return cls.from_env("mainnet-beta")
77
+
78
+ @classmethod
79
+ def testnet(cls) -> Client:
80
+ """Create a client configured for testnet."""
81
+ return cls.from_env("testnet")
82
+
83
+ @classmethod
84
+ def devnet(cls) -> Client:
85
+ """Create a client configured for devnet."""
86
+ return cls.from_env("devnet")
87
+
88
+ @classmethod
89
+ def localnet(cls) -> Client:
90
+ """Create a client configured for localnet."""
91
+ return cls.from_env("localnet")
92
+
93
+ # -- Solana RPC (on-chain accounts) --
94
+
95
+ def fetch_config(self) -> ProgramConfig:
96
+ addr, _ = derive_config_pda(self._program_id)
97
+ data = self._fetch_solana_account_data(addr)
98
+ return ProgramConfig.from_bytes(data, DISCRIMINATOR_PROGRAM_CONFIG)
99
+
100
+ def fetch_distribution(self, epoch: int) -> Distribution:
101
+ addr, _ = derive_distribution_pda(self._program_id, epoch)
102
+ data = self._fetch_solana_account_data(addr)
103
+ return Distribution.from_bytes(data, DISCRIMINATOR_DISTRIBUTION)
104
+
105
+ def fetch_journal(self) -> Journal:
106
+ addr, _ = derive_journal_pda(self._program_id)
107
+ data = self._fetch_solana_account_data(addr)
108
+ return Journal.from_bytes(data, DISCRIMINATOR_JOURNAL)
109
+
110
+ def fetch_validator_deposit(
111
+ self, node_id: Pubkey
112
+ ) -> SolanaValidatorDeposit:
113
+ addr, _ = derive_validator_deposit_pda(self._program_id, node_id)
114
+ data = self._fetch_solana_account_data(addr)
115
+ return SolanaValidatorDeposit.from_bytes(
116
+ data, DISCRIMINATOR_SOLANA_VALIDATOR_DEPOSIT
117
+ )
118
+
119
+ def fetch_contributor_rewards(
120
+ self, service_key: Pubkey
121
+ ) -> ContributorRewards:
122
+ addr, _ = derive_contributor_rewards_pda(self._program_id, service_key)
123
+ data = self._fetch_solana_account_data(addr)
124
+ return ContributorRewards.from_bytes(
125
+ data, DISCRIMINATOR_CONTRIBUTOR_REWARDS
126
+ )
127
+
128
+ def fetch_all_validator_deposits(self) -> list[SolanaValidatorDeposit]:
129
+ return self._fetch_all_by_discriminator(
130
+ DISCRIMINATOR_SOLANA_VALIDATOR_DEPOSIT,
131
+ SolanaValidatorDeposit,
132
+ )
133
+
134
+ def fetch_all_contributor_rewards(self) -> list[ContributorRewards]:
135
+ return self._fetch_all_by_discriminator(
136
+ DISCRIMINATOR_CONTRIBUTOR_REWARDS,
137
+ ContributorRewards,
138
+ )
139
+
140
+ # -- DZ Ledger RPC (ledger records) --
141
+
142
+ def fetch_validator_debts(
143
+ self, epoch: int
144
+ ) -> ComputedSolanaValidatorDebts:
145
+ config = self.fetch_config()
146
+ addr = derive_validator_debt_record_key(config.debt_accountant_key, epoch)
147
+ data = self._fetch_ledger_record_data(addr)
148
+ return ComputedSolanaValidatorDebts.from_bytes(data[RECORD_HEADER_SIZE:])
149
+
150
+ def fetch_reward_shares(self, epoch: int) -> ShapleyOutputStorage:
151
+ config = self.fetch_config()
152
+ addr = derive_reward_share_record_key(config.rewards_accountant_key, epoch)
153
+ data = self._fetch_ledger_record_data(addr)
154
+ return ShapleyOutputStorage.from_bytes(data[RECORD_HEADER_SIZE:])
155
+
156
+ # -- Internal helpers --
157
+
158
+ def _fetch_solana_account_data(self, addr: Pubkey) -> bytes:
159
+ resp = self._solana_rpc.get_account_info(addr)
160
+ if resp.value is None:
161
+ raise ValueError(f"account not found: {addr}")
162
+ return bytes(resp.value.data)
163
+
164
+ def _fetch_ledger_record_data(self, addr: Pubkey) -> bytes:
165
+ resp = self._ledger_rpc.get_account_info(addr)
166
+ if resp.value is None:
167
+ raise ValueError(f"ledger record not found: {addr}")
168
+ return bytes(resp.value.data)
169
+
170
+ def _fetch_all_by_discriminator(
171
+ self,
172
+ disc: bytes,
173
+ cls: type,
174
+ ) -> list:
175
+ from solana.rpc.types import MemcmpOpts # type: ignore[import-untyped]
176
+
177
+ import base58 # type: ignore[import-untyped]
178
+
179
+ filters = [MemcmpOpts(offset=0, bytes=base58.b58encode(disc).decode())]
180
+ resp = self._solana_rpc.get_program_accounts(
181
+ self._program_id,
182
+ encoding="base64",
183
+ filters=filters,
184
+ )
185
+ results = []
186
+ for acct in resp.value:
187
+ data = bytes(acct.account.data)
188
+ results.append(cls.from_bytes(data, disc))
189
+ return results
@@ -0,0 +1,22 @@
1
+ """Network configuration for the revenue distribution program."""
2
+
3
+ PROGRAM_ID = "dzrevZC94tBLwuHw1dyynZxaXTWyp7yocsinyEVPtt4"
4
+
5
+ SOLANA_RPC_URLS = {
6
+ "mainnet-beta": "https://api.mainnet-beta.solana.com",
7
+ "testnet": "https://api.testnet.solana.com",
8
+ "devnet": "https://api.devnet.solana.com",
9
+ "localnet": "http://localhost:8899",
10
+ }
11
+
12
+ ORACLE_URLS = {
13
+ "mainnet-beta": "https://sol-2z-oracle-api-v1.mainnet-beta.doublezero.xyz",
14
+ "testnet": "https://sol-2z-oracle-api-v1.testnet.doublezero.xyz",
15
+ }
16
+
17
+ LEDGER_RPC_URLS = {
18
+ "mainnet-beta": "https://doublezero-mainnet-beta.rpcpool.com/db336024-e7a8-46b1-80e5-352dd77060ab",
19
+ "testnet": "https://doublezerolocalnet.rpcpool.com/8a4fd3f4-0977-449f-88c7-63d4b0f10f16",
20
+ "devnet": "https://doublezerolocalnet.rpcpool.com/8a4fd3f4-0977-449f-88c7-63d4b0f10f16",
21
+ "localnet": "http://localhost:8899",
22
+ }
@@ -0,0 +1,31 @@
1
+ import hashlib
2
+
3
+ DISCRIMINATOR_SIZE = 8
4
+
5
+
6
+ def _sha256_first8(s: str) -> bytes:
7
+ return hashlib.sha256(s.encode()).digest()[:8]
8
+
9
+
10
+ DISCRIMINATOR_PROGRAM_CONFIG = _sha256_first8("dz::account::program_config")
11
+ DISCRIMINATOR_DISTRIBUTION = _sha256_first8("dz::account::distribution")
12
+ DISCRIMINATOR_SOLANA_VALIDATOR_DEPOSIT = _sha256_first8(
13
+ "dz::account::solana_validator_deposit"
14
+ )
15
+ DISCRIMINATOR_CONTRIBUTOR_REWARDS = _sha256_first8(
16
+ "dz::account::contributor_rewards"
17
+ )
18
+ DISCRIMINATOR_JOURNAL = _sha256_first8("dz::account::journal")
19
+
20
+
21
+ def validate_discriminator(data: bytes, expected: bytes) -> None:
22
+ """Validate the 8-byte discriminator prefix. Raises ValueError on mismatch."""
23
+ if len(data) < DISCRIMINATOR_SIZE:
24
+ raise ValueError(
25
+ f"data too short: {len(data)} bytes, need at least {DISCRIMINATOR_SIZE}"
26
+ )
27
+ got = data[:DISCRIMINATOR_SIZE]
28
+ if got != expected:
29
+ raise ValueError(
30
+ f"invalid discriminator: got {got.hex()}, want {expected.hex()}"
31
+ )
@@ -0,0 +1,38 @@
1
+ """SOL/2Z oracle client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import httpx
8
+
9
+
10
+ @dataclass
11
+ class SwapRate:
12
+ rate: float
13
+ timestamp: int
14
+ signature: str
15
+ sol_price_usd: str
16
+ twoz_price_usd: str
17
+ cache_hit: bool
18
+
19
+
20
+ class OracleClient:
21
+ """Fetches SOL/2Z swap rates from the oracle API."""
22
+
23
+ def __init__(self, base_url: str) -> None:
24
+ self._base_url = base_url
25
+ self._http = httpx.Client(timeout=30)
26
+
27
+ def fetch_swap_rate(self) -> SwapRate:
28
+ resp = self._http.get(f"{self._base_url}/swap-rate")
29
+ resp.raise_for_status()
30
+ data = resp.json()
31
+ return SwapRate(
32
+ rate=data["swapRate"],
33
+ timestamp=data["timestamp"],
34
+ signature=data["signature"],
35
+ sol_price_usd=data["solPriceUsd"],
36
+ twoz_price_usd=data["twozPriceUsd"],
37
+ cache_hit=data["cacheHit"],
38
+ )
@@ -0,0 +1,85 @@
1
+ """PDA and record key derivation for revenue distribution program accounts."""
2
+
3
+ import hashlib
4
+ import struct
5
+
6
+ import base58 # type: ignore[import-untyped]
7
+ from solders.pubkey import Pubkey # type: ignore[import-untyped]
8
+
9
+ SEED_PROGRAM_CONFIG = b"program_config"
10
+ SEED_DISTRIBUTION = b"distribution"
11
+ SEED_SOLANA_VALIDATOR_DEPOSIT = b"solana_validator_deposit"
12
+ SEED_CONTRIBUTOR_REWARDS = b"contributor_rewards"
13
+ SEED_JOURNAL = b"journal"
14
+ SEED_SOLANA_VALIDATOR_DEBT = b"solana_validator_debt"
15
+ SEED_DZ_CONTRIBUTOR_REWARDS = b"dz_contributor_rewards"
16
+ SEED_SHAPLEY_OUTPUT = b"shapley_output"
17
+
18
+ RECORD_PROGRAM_ID = Pubkey.from_string("dzrecxigtaZQ3gPmt2X5mDkYigaruFR1rHCqztFTvx7")
19
+ RECORD_HEADER_SIZE = 33
20
+
21
+
22
+ def derive_config_pda(program_id: Pubkey) -> tuple[Pubkey, int]:
23
+ return Pubkey.find_program_address([SEED_PROGRAM_CONFIG], program_id)
24
+
25
+
26
+ def derive_distribution_pda(
27
+ program_id: Pubkey, epoch: int
28
+ ) -> tuple[Pubkey, int]:
29
+ epoch_bytes = struct.pack("<Q", epoch)
30
+ return Pubkey.find_program_address(
31
+ [SEED_DISTRIBUTION, epoch_bytes], program_id
32
+ )
33
+
34
+
35
+ def derive_journal_pda(program_id: Pubkey) -> tuple[Pubkey, int]:
36
+ return Pubkey.find_program_address([SEED_JOURNAL], program_id)
37
+
38
+
39
+ def derive_validator_deposit_pda(
40
+ program_id: Pubkey, node_id: Pubkey
41
+ ) -> tuple[Pubkey, int]:
42
+ return Pubkey.find_program_address(
43
+ [SEED_SOLANA_VALIDATOR_DEPOSIT, bytes(node_id)], program_id
44
+ )
45
+
46
+
47
+ def derive_contributor_rewards_pda(
48
+ program_id: Pubkey, service_key: Pubkey
49
+ ) -> tuple[Pubkey, int]:
50
+ return Pubkey.find_program_address(
51
+ [SEED_CONTRIBUTOR_REWARDS, bytes(service_key)], program_id
52
+ )
53
+
54
+
55
+ def _create_record_seed_string(seeds: list[bytes]) -> str:
56
+ """Hash seeds with SHA256, encode as base58, truncate to 32 chars."""
57
+ h = hashlib.sha256()
58
+ for s in seeds:
59
+ h.update(s)
60
+ return base58.b58encode(h.digest()).decode()[:32]
61
+
62
+
63
+ def derive_record_key(payer_key: Pubkey, seeds: list[bytes]) -> Pubkey:
64
+ """Derive a ledger record address using create-with-seed."""
65
+ seed_str = _create_record_seed_string(seeds)
66
+ return Pubkey.create_with_seed(payer_key, seed_str, RECORD_PROGRAM_ID)
67
+
68
+
69
+ def derive_validator_debt_record_key(
70
+ debt_accountant_key: Pubkey, epoch: int
71
+ ) -> Pubkey:
72
+ epoch_bytes = struct.pack("<Q", epoch)
73
+ return derive_record_key(
74
+ debt_accountant_key, [SEED_SOLANA_VALIDATOR_DEBT, epoch_bytes]
75
+ )
76
+
77
+
78
+ def derive_reward_share_record_key(
79
+ rewards_accountant_key: Pubkey, epoch: int
80
+ ) -> Pubkey:
81
+ epoch_bytes = struct.pack("<Q", epoch)
82
+ return derive_record_key(
83
+ rewards_accountant_key,
84
+ [SEED_DZ_CONTRIBUTOR_REWARDS, epoch_bytes, SEED_SHAPLEY_OUTPUT],
85
+ )
@@ -0,0 +1,19 @@
1
+ """Wrapper for reserved / padding bytes in on-chain account layouts."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class Reserved(bytes):
7
+ """Thin wrapper around ``bytes`` that marks padding or storage-gap fields.
8
+
9
+ Behaves exactly like ``bytes`` but makes the intent explicit when used as
10
+ a type annotation in dataclasses::
11
+
12
+ reserved0: Reserved # [3]u8 padding
13
+ """
14
+
15
+ def __new__(cls, data: bytes) -> Reserved:
16
+ return super().__new__(cls, data)
17
+
18
+ def __repr__(self) -> str:
19
+ return f"Reserved({len(self)})"
@@ -0,0 +1,49 @@
1
+ """RPC client helpers with retry on rate limiting."""
2
+
3
+ import time
4
+
5
+ import httpx
6
+ from solana.rpc.api import Client as SolanaHTTPClient # type: ignore[import-untyped]
7
+ from solana.rpc.providers.http import HTTPProvider # type: ignore[import-untyped]
8
+
9
+ _DEFAULT_MAX_RETRIES = 5
10
+
11
+
12
+ class _RetryTransport(httpx.BaseTransport):
13
+ """HTTP transport that retries on 429 Too Many Requests."""
14
+
15
+ def __init__(
16
+ self,
17
+ wrapped: httpx.BaseTransport | None = None,
18
+ max_retries: int = _DEFAULT_MAX_RETRIES,
19
+ ) -> None:
20
+ self._wrapped = wrapped or httpx.HTTPTransport()
21
+ self._max_retries = max_retries
22
+
23
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
24
+ for attempt in range(self._max_retries + 1):
25
+ response = self._wrapped.handle_request(request)
26
+ if response.status_code != 429 or attempt >= self._max_retries:
27
+ return response
28
+ response.close()
29
+ time.sleep((attempt + 1) * 2)
30
+ return response # unreachable, but satisfies type checker
31
+
32
+
33
+ def new_rpc_client(
34
+ url: str,
35
+ timeout: float = 30,
36
+ max_retries: int = _DEFAULT_MAX_RETRIES,
37
+ ) -> SolanaHTTPClient:
38
+ """Create a Solana RPC client with automatic retry on 429 responses."""
39
+ client = SolanaHTTPClient(url, timeout=timeout)
40
+ # Replace the underlying httpx session with one using retry transport.
41
+ transport = _RetryTransport(
42
+ wrapped=httpx.HTTPTransport(),
43
+ max_retries=max_retries,
44
+ )
45
+ client._provider.session = httpx.Client(
46
+ timeout=timeout,
47
+ transport=transport,
48
+ )
49
+ return client