aether-chain-client 0.2.9__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.
Files changed (29) hide show
  1. aether_chain_client-0.2.9/PKG-INFO +76 -0
  2. aether_chain_client-0.2.9/README.md +58 -0
  3. aether_chain_client-0.2.9/aether_chain_client.egg-info/PKG-INFO +76 -0
  4. aether_chain_client-0.2.9/aether_chain_client.egg-info/SOURCES.txt +27 -0
  5. aether_chain_client-0.2.9/aether_chain_client.egg-info/dependency_links.txt +1 -0
  6. aether_chain_client-0.2.9/aether_chain_client.egg-info/requires.txt +2 -0
  7. aether_chain_client-0.2.9/aether_chain_client.egg-info/top_level.txt +1 -0
  8. aether_chain_client-0.2.9/aether_client/__init__.py +19 -0
  9. aether_chain_client-0.2.9/aether_client/amount.py +198 -0
  10. aether_chain_client-0.2.9/aether_client/bech32.py +64 -0
  11. aether_chain_client-0.2.9/aether_client/client.py +284 -0
  12. aether_chain_client-0.2.9/aether_client/directory.py +200 -0
  13. aether_chain_client-0.2.9/aether_client/keys.py +77 -0
  14. aether_chain_client-0.2.9/aether_client/ledger.py +316 -0
  15. aether_chain_client-0.2.9/aether_client/paywall.py +386 -0
  16. aether_chain_client-0.2.9/aether_client/proto.py +92 -0
  17. aether_chain_client-0.2.9/aether_client/receipt.py +159 -0
  18. aether_chain_client-0.2.9/aether_client/rpc.py +77 -0
  19. aether_chain_client-0.2.9/aether_client/seller.py +1060 -0
  20. aether_chain_client-0.2.9/aether_client/tx.py +103 -0
  21. aether_chain_client-0.2.9/aether_client/withdraw.py +90 -0
  22. aether_chain_client-0.2.9/pyproject.toml +27 -0
  23. aether_chain_client-0.2.9/setup.cfg +4 -0
  24. aether_chain_client-0.2.9/tests/test_contract.py +28 -0
  25. aether_chain_client-0.2.9/tests/test_pull.py +192 -0
  26. aether_chain_client-0.2.9/tests/test_reputation.py +60 -0
  27. aether_chain_client-0.2.9/tests/test_seller.py +458 -0
  28. aether_chain_client-0.2.9/tests/test_usdc.py +217 -0
  29. aether_chain_client-0.2.9/tests/test_vectors.py +114 -0
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: aether-chain-client
3
+ Version: 0.2.9
4
+ Summary: Python client for the Aether chain: ML-DSA-44 keys, payments, buying and selling paid APIs (x402 aether-memo / aether-prepaid) and the service directory.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/whoyoujoshin/aether/tree/main/clients/python#readme
7
+ Project-URL: Repository, https://github.com/whoyoujoshin/aether
8
+ Project-URL: Issues, https://github.com/whoyoujoshin/aether/issues
9
+ Keywords: aether,ai-agents,agent-payments,x402,mcp,blockchain,cosmos,post-quantum,ml-dsa,usdc
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: dilithium-py<2,>=1.4
17
+ Requires-Dist: mnemonic>=0.21
18
+
19
+ # aether-chain-client
20
+
21
+ Python client for the [Aether](https://github.com/whoyoujoshin/aether) chain: ML-DSA-44 keys, sending and receiving AETH, buying from and selling paid APIs (x402 `aether-memo`, `aether-prepaid` and `aether-pull`) and the on-chain service directory. It talks to a node's CometBFT RPC (port 26657) only.
22
+
23
+ ```python
24
+ import os
25
+ from aether_client import AetherClient, Key, fetch_paid, find_services, rate_service, withdraw_prepaid
26
+
27
+ client = AetherClient("http://localhost:26657", "aether-testnet-1")
28
+ key = Key.from_mnemonic(os.environ["AETHER_MNEMONIC"]) # same address as `aetherd keys add` / `agentmcp`
29
+
30
+ sent = client.send(key, "aether1...", "0.5 AETH", memo="invoice-42")
31
+ done = client.wait_for_transaction(sent.hash) # confirmed | failed | pending (timeout)
32
+ # Retrying after an error: client.rebroadcast(sent.signed) -- never a second send().
33
+
34
+ res = fetch_paid(client, key, "https://api.example.com/forecast", max_amount="0.05 AETH", prepay="1 AETH")
35
+ # Or, where offered, pay from your own account under a capped, 7-day allowance the seller collects from later:
36
+ res2 = fetch_paid(client, key, "https://api.example.com/forecast", max_amount="0.05 AETH", pull_allowance="1 AETH")
37
+ services = find_services(client, query="weather", max_price="0.1 AETH", trusted=[owner_address])
38
+ # services[i].reputation: payments, payers, ratings (can be faked), trusted_ratings (can't)
39
+ # res.receipt: the seller's signed receipt and whether it matches exactly what was sent and received
40
+ rate_service(client, key, "https://api.example.com", 5) # only raters who paid a service count
41
+ back = withdraw_prepaid(client, key, "https://api.example.com", withdrawal_id="w1") # unspent prepaid balance
42
+ ```
43
+
44
+ ## Selling
45
+
46
+ Charge per request from a Python service -- all three payment schemes, the `/.well-known/x402` manifest for the service directory, and withdrawals of unspent prepaid balances. Compatible with the Go paywall and every Aether buyer.
47
+
48
+ ```python
49
+ import os
50
+ from fastapi import FastAPI, Request
51
+ from aether_client import AetherClient, Key, Paywall
52
+
53
+ client = AetherClient("http://localhost:26657", "aether-testnet-1")
54
+ pw = Paywall(client, "aether1...", "0.01 AETH", name="Weather", description="Forecasts by city",
55
+ prepaid_ledger="ledger.json", # customers' balances: back it up
56
+ min_deposit="0.1 AETH",
57
+ payout_key=Key.from_mnemonic(os.environ["PAYOUT_MNEMONIC"]), # pays withdrawals; keep a small float in it
58
+ receipt_key=payee_key, # sign a receipt for every paid response (or a delegated key + receipt_delegation)
59
+ pull_collector_key=Key.from_mnemonic(os.environ["COLLECTOR_MNEMONIC"]), pull_credit="1 AETH") # aether-pull: needs no funds
60
+ pw.start_collecting() # collects what aether-pull buyers owe, in batches
61
+ api = FastAPI()
62
+
63
+ @api.get("/forecast")
64
+ async def forecast(request: Request):
65
+ return {"paid_by": request.scope["aether"].payer}
66
+
67
+ app = pw.asgi(api, free=["/health"]) # Flask/Django: app.wsgi_app = pw.wsgi(app.wsgi_app); who paid: environ["aether.payment"]
68
+ ```
69
+
70
+ The ledger is one JSON file (the Go paywall's format) for one process. Chain lookups block, so the ASGI adapter runs them in a thread.
71
+
72
+ Amounts always carry a unit (`"1.5 AETH"`, `"1500000uaeth"`).
73
+
74
+ **USDC.** `AetherClient(rpc, chain_id, usdc_channel="channel-N")` (Aether's end of its channel to Noble) also knows USDC: Noble's `uusdc` over exactly that channel, never a lookalike that arrived another way. For USDC that arrives another route, give its denom trace and its denom on the issuing chain instead: `usdc_path="transfer/channel-2", usdc_base_denom="erc20:0x0C382e685bbeeFE5d3d9C29e29E341fEE8E84C5d", usdc_issuer="Injective"` (`usdc_base_denom` defaults to `uusdc`). Then `send(key, to, "5 USDC")` sends USDC, `balances(address)` lists each asset, `wait_for_payment` matches in the asset of `min_amount`, and `fetch_paid` pays services priced in USDC with `max_amount` (and `prepay` / `pull_allowance`) in USDC; an amount in another asset than the service charges is refused with `ASSET_MISMATCH` before anything is paid. Results carry `asset`, `amount`, `balance`, `owed` and `allowance` in that asset's base units; the `*_uaeth` fields are set only for AETH. Without the channel, `"5 USDC"` is an unknown unit. The seller kit charges USDC too: `Paywall(client, pay_to, "0.05 USDC", usdc_channel="channel-N")` charges Noble USDC for everything (payments, deposits and balances, pull allowances and collections, withdrawals), `min_deposit` and `pull_credit` must then be in USDC, and 402s, the manifest and receipts carry the asset exactly as `cmd/paywall`'s do.
75
+
76
+ Tests: `python -m unittest discover -s tests` (checked against `../testdata/vectors.json`, generated by the Go code).
@@ -0,0 +1,58 @@
1
+ # aether-chain-client
2
+
3
+ Python client for the [Aether](https://github.com/whoyoujoshin/aether) chain: ML-DSA-44 keys, sending and receiving AETH, buying from and selling paid APIs (x402 `aether-memo`, `aether-prepaid` and `aether-pull`) and the on-chain service directory. It talks to a node's CometBFT RPC (port 26657) only.
4
+
5
+ ```python
6
+ import os
7
+ from aether_client import AetherClient, Key, fetch_paid, find_services, rate_service, withdraw_prepaid
8
+
9
+ client = AetherClient("http://localhost:26657", "aether-testnet-1")
10
+ key = Key.from_mnemonic(os.environ["AETHER_MNEMONIC"]) # same address as `aetherd keys add` / `agentmcp`
11
+
12
+ sent = client.send(key, "aether1...", "0.5 AETH", memo="invoice-42")
13
+ done = client.wait_for_transaction(sent.hash) # confirmed | failed | pending (timeout)
14
+ # Retrying after an error: client.rebroadcast(sent.signed) -- never a second send().
15
+
16
+ res = fetch_paid(client, key, "https://api.example.com/forecast", max_amount="0.05 AETH", prepay="1 AETH")
17
+ # Or, where offered, pay from your own account under a capped, 7-day allowance the seller collects from later:
18
+ res2 = fetch_paid(client, key, "https://api.example.com/forecast", max_amount="0.05 AETH", pull_allowance="1 AETH")
19
+ services = find_services(client, query="weather", max_price="0.1 AETH", trusted=[owner_address])
20
+ # services[i].reputation: payments, payers, ratings (can be faked), trusted_ratings (can't)
21
+ # res.receipt: the seller's signed receipt and whether it matches exactly what was sent and received
22
+ rate_service(client, key, "https://api.example.com", 5) # only raters who paid a service count
23
+ back = withdraw_prepaid(client, key, "https://api.example.com", withdrawal_id="w1") # unspent prepaid balance
24
+ ```
25
+
26
+ ## Selling
27
+
28
+ Charge per request from a Python service -- all three payment schemes, the `/.well-known/x402` manifest for the service directory, and withdrawals of unspent prepaid balances. Compatible with the Go paywall and every Aether buyer.
29
+
30
+ ```python
31
+ import os
32
+ from fastapi import FastAPI, Request
33
+ from aether_client import AetherClient, Key, Paywall
34
+
35
+ client = AetherClient("http://localhost:26657", "aether-testnet-1")
36
+ pw = Paywall(client, "aether1...", "0.01 AETH", name="Weather", description="Forecasts by city",
37
+ prepaid_ledger="ledger.json", # customers' balances: back it up
38
+ min_deposit="0.1 AETH",
39
+ payout_key=Key.from_mnemonic(os.environ["PAYOUT_MNEMONIC"]), # pays withdrawals; keep a small float in it
40
+ receipt_key=payee_key, # sign a receipt for every paid response (or a delegated key + receipt_delegation)
41
+ pull_collector_key=Key.from_mnemonic(os.environ["COLLECTOR_MNEMONIC"]), pull_credit="1 AETH") # aether-pull: needs no funds
42
+ pw.start_collecting() # collects what aether-pull buyers owe, in batches
43
+ api = FastAPI()
44
+
45
+ @api.get("/forecast")
46
+ async def forecast(request: Request):
47
+ return {"paid_by": request.scope["aether"].payer}
48
+
49
+ app = pw.asgi(api, free=["/health"]) # Flask/Django: app.wsgi_app = pw.wsgi(app.wsgi_app); who paid: environ["aether.payment"]
50
+ ```
51
+
52
+ The ledger is one JSON file (the Go paywall's format) for one process. Chain lookups block, so the ASGI adapter runs them in a thread.
53
+
54
+ Amounts always carry a unit (`"1.5 AETH"`, `"1500000uaeth"`).
55
+
56
+ **USDC.** `AetherClient(rpc, chain_id, usdc_channel="channel-N")` (Aether's end of its channel to Noble) also knows USDC: Noble's `uusdc` over exactly that channel, never a lookalike that arrived another way. For USDC that arrives another route, give its denom trace and its denom on the issuing chain instead: `usdc_path="transfer/channel-2", usdc_base_denom="erc20:0x0C382e685bbeeFE5d3d9C29e29E341fEE8E84C5d", usdc_issuer="Injective"` (`usdc_base_denom` defaults to `uusdc`). Then `send(key, to, "5 USDC")` sends USDC, `balances(address)` lists each asset, `wait_for_payment` matches in the asset of `min_amount`, and `fetch_paid` pays services priced in USDC with `max_amount` (and `prepay` / `pull_allowance`) in USDC; an amount in another asset than the service charges is refused with `ASSET_MISMATCH` before anything is paid. Results carry `asset`, `amount`, `balance`, `owed` and `allowance` in that asset's base units; the `*_uaeth` fields are set only for AETH. Without the channel, `"5 USDC"` is an unknown unit. The seller kit charges USDC too: `Paywall(client, pay_to, "0.05 USDC", usdc_channel="channel-N")` charges Noble USDC for everything (payments, deposits and balances, pull allowances and collections, withdrawals), `min_deposit` and `pull_credit` must then be in USDC, and 402s, the manifest and receipts carry the asset exactly as `cmd/paywall`'s do.
57
+
58
+ Tests: `python -m unittest discover -s tests` (checked against `../testdata/vectors.json`, generated by the Go code).
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: aether-chain-client
3
+ Version: 0.2.9
4
+ Summary: Python client for the Aether chain: ML-DSA-44 keys, payments, buying and selling paid APIs (x402 aether-memo / aether-prepaid) and the service directory.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/whoyoujoshin/aether/tree/main/clients/python#readme
7
+ Project-URL: Repository, https://github.com/whoyoujoshin/aether
8
+ Project-URL: Issues, https://github.com/whoyoujoshin/aether/issues
9
+ Keywords: aether,ai-agents,agent-payments,x402,mcp,blockchain,cosmos,post-quantum,ml-dsa,usdc
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: dilithium-py<2,>=1.4
17
+ Requires-Dist: mnemonic>=0.21
18
+
19
+ # aether-chain-client
20
+
21
+ Python client for the [Aether](https://github.com/whoyoujoshin/aether) chain: ML-DSA-44 keys, sending and receiving AETH, buying from and selling paid APIs (x402 `aether-memo`, `aether-prepaid` and `aether-pull`) and the on-chain service directory. It talks to a node's CometBFT RPC (port 26657) only.
22
+
23
+ ```python
24
+ import os
25
+ from aether_client import AetherClient, Key, fetch_paid, find_services, rate_service, withdraw_prepaid
26
+
27
+ client = AetherClient("http://localhost:26657", "aether-testnet-1")
28
+ key = Key.from_mnemonic(os.environ["AETHER_MNEMONIC"]) # same address as `aetherd keys add` / `agentmcp`
29
+
30
+ sent = client.send(key, "aether1...", "0.5 AETH", memo="invoice-42")
31
+ done = client.wait_for_transaction(sent.hash) # confirmed | failed | pending (timeout)
32
+ # Retrying after an error: client.rebroadcast(sent.signed) -- never a second send().
33
+
34
+ res = fetch_paid(client, key, "https://api.example.com/forecast", max_amount="0.05 AETH", prepay="1 AETH")
35
+ # Or, where offered, pay from your own account under a capped, 7-day allowance the seller collects from later:
36
+ res2 = fetch_paid(client, key, "https://api.example.com/forecast", max_amount="0.05 AETH", pull_allowance="1 AETH")
37
+ services = find_services(client, query="weather", max_price="0.1 AETH", trusted=[owner_address])
38
+ # services[i].reputation: payments, payers, ratings (can be faked), trusted_ratings (can't)
39
+ # res.receipt: the seller's signed receipt and whether it matches exactly what was sent and received
40
+ rate_service(client, key, "https://api.example.com", 5) # only raters who paid a service count
41
+ back = withdraw_prepaid(client, key, "https://api.example.com", withdrawal_id="w1") # unspent prepaid balance
42
+ ```
43
+
44
+ ## Selling
45
+
46
+ Charge per request from a Python service -- all three payment schemes, the `/.well-known/x402` manifest for the service directory, and withdrawals of unspent prepaid balances. Compatible with the Go paywall and every Aether buyer.
47
+
48
+ ```python
49
+ import os
50
+ from fastapi import FastAPI, Request
51
+ from aether_client import AetherClient, Key, Paywall
52
+
53
+ client = AetherClient("http://localhost:26657", "aether-testnet-1")
54
+ pw = Paywall(client, "aether1...", "0.01 AETH", name="Weather", description="Forecasts by city",
55
+ prepaid_ledger="ledger.json", # customers' balances: back it up
56
+ min_deposit="0.1 AETH",
57
+ payout_key=Key.from_mnemonic(os.environ["PAYOUT_MNEMONIC"]), # pays withdrawals; keep a small float in it
58
+ receipt_key=payee_key, # sign a receipt for every paid response (or a delegated key + receipt_delegation)
59
+ pull_collector_key=Key.from_mnemonic(os.environ["COLLECTOR_MNEMONIC"]), pull_credit="1 AETH") # aether-pull: needs no funds
60
+ pw.start_collecting() # collects what aether-pull buyers owe, in batches
61
+ api = FastAPI()
62
+
63
+ @api.get("/forecast")
64
+ async def forecast(request: Request):
65
+ return {"paid_by": request.scope["aether"].payer}
66
+
67
+ app = pw.asgi(api, free=["/health"]) # Flask/Django: app.wsgi_app = pw.wsgi(app.wsgi_app); who paid: environ["aether.payment"]
68
+ ```
69
+
70
+ The ledger is one JSON file (the Go paywall's format) for one process. Chain lookups block, so the ASGI adapter runs them in a thread.
71
+
72
+ Amounts always carry a unit (`"1.5 AETH"`, `"1500000uaeth"`).
73
+
74
+ **USDC.** `AetherClient(rpc, chain_id, usdc_channel="channel-N")` (Aether's end of its channel to Noble) also knows USDC: Noble's `uusdc` over exactly that channel, never a lookalike that arrived another way. For USDC that arrives another route, give its denom trace and its denom on the issuing chain instead: `usdc_path="transfer/channel-2", usdc_base_denom="erc20:0x0C382e685bbeeFE5d3d9C29e29E341fEE8E84C5d", usdc_issuer="Injective"` (`usdc_base_denom` defaults to `uusdc`). Then `send(key, to, "5 USDC")` sends USDC, `balances(address)` lists each asset, `wait_for_payment` matches in the asset of `min_amount`, and `fetch_paid` pays services priced in USDC with `max_amount` (and `prepay` / `pull_allowance`) in USDC; an amount in another asset than the service charges is refused with `ASSET_MISMATCH` before anything is paid. Results carry `asset`, `amount`, `balance`, `owed` and `allowance` in that asset's base units; the `*_uaeth` fields are set only for AETH. Without the channel, `"5 USDC"` is an unknown unit. The seller kit charges USDC too: `Paywall(client, pay_to, "0.05 USDC", usdc_channel="channel-N")` charges Noble USDC for everything (payments, deposits and balances, pull allowances and collections, withdrawals), `min_deposit` and `pull_credit` must then be in USDC, and 402s, the manifest and receipts carry the asset exactly as `cmd/paywall`'s do.
75
+
76
+ Tests: `python -m unittest discover -s tests` (checked against `../testdata/vectors.json`, generated by the Go code).
@@ -0,0 +1,27 @@
1
+ README.md
2
+ pyproject.toml
3
+ aether_chain_client.egg-info/PKG-INFO
4
+ aether_chain_client.egg-info/SOURCES.txt
5
+ aether_chain_client.egg-info/dependency_links.txt
6
+ aether_chain_client.egg-info/requires.txt
7
+ aether_chain_client.egg-info/top_level.txt
8
+ aether_client/__init__.py
9
+ aether_client/amount.py
10
+ aether_client/bech32.py
11
+ aether_client/client.py
12
+ aether_client/directory.py
13
+ aether_client/keys.py
14
+ aether_client/ledger.py
15
+ aether_client/paywall.py
16
+ aether_client/proto.py
17
+ aether_client/receipt.py
18
+ aether_client/rpc.py
19
+ aether_client/seller.py
20
+ aether_client/tx.py
21
+ aether_client/withdraw.py
22
+ tests/test_contract.py
23
+ tests/test_pull.py
24
+ tests/test_reputation.py
25
+ tests/test_seller.py
26
+ tests/test_usdc.py
27
+ tests/test_vectors.py
@@ -0,0 +1,2 @@
1
+ dilithium-py<2,>=1.4
2
+ mnemonic>=0.21
@@ -0,0 +1,19 @@
1
+ """Python client for the Aether chain."""
2
+
3
+ from .amount import (AETH, DENOM, Asset, Assets, decimal_of, format_aeth, format_amount, parse_amount, parse_uaeth, receipt_amount,
4
+ usdc, usdc_at, usdc_for, DEFAULT_USDC_BASE_DENOM)
5
+ from .client import AetherClient, IncomingPayment, SendGrant, SendResult, TransactionInfo, Transfer, decode_send_grant, transfers
6
+ from .directory import (ANNOUNCE_PREFIX, DEFAULT_WINDOW, DELIST_PREFIX, DIRECTORY_ADDRESS, RATE_PREFIX, Rating, RatingSummary, Reputation,
7
+ Service, fetch_manifest, find_services, normalize_url, rate_service, rating_memo)
8
+ from .keys import PREFIX, Key, address_bytes, address_of, is_address
9
+ from .paywall import (DEPOSIT_MEMO_PREFIX, PULL_GRANT_SECONDS, SCHEME_MEMO, SCHEME_PREPAID, SCHEME_PULL, FetchPaidResult, PaymentError,
10
+ fetch_paid, memo_payment_header, prepaid_payment_header, present_payment, pull_payment_header,
11
+ pull_signing_message, signing_message)
12
+ from .rpc import Rpc, RpcError
13
+ from .tx import (DEFAULT_GAS_LIMIT, MSG_EXEC_TYPE_URL, MSG_GRANT_TYPE_URL, SignedTx, build_send, build_tx, exec_send_msg,
14
+ grant_send_msg, memo_of)
15
+ from .withdraw import WithdrawResult, withdraw_prepaid
16
+ from .ledger import FileLedger, LedgerError
17
+ from .seller import WITHDRAW_PATH, KeyPayout, Payment, Paywall, Respond, SellerRequest, Serve
18
+ from .receipt import (RECEIPT_HEADER, ReceiptCheck, check_receipt, create_receipt_delegation, decode_receipt,
19
+ delegation_signing_message, receipt_signing_message, sign_receipt, verify_receipt)
@@ -0,0 +1,198 @@
1
+ """Amounts carry a unit: "1.5 AETH" or "1500000uaeth" (1 AETH = 1,000,000 uaeth).
2
+
3
+ A bare number is refused rather than guessed at -- mixing the two up is a
4
+ million-fold error with real money.
5
+ """
6
+
7
+ import hashlib
8
+ import re
9
+ from dataclasses import dataclass
10
+ from typing import List, Optional, Tuple
11
+
12
+ DENOM = "uaeth"
13
+ _DECIMALS = 6
14
+ _PATTERN = re.compile(r"^\s*([0-9]+)(?:\.([0-9]+))?\s*([A-Za-z]+)\s*$")
15
+ _MAX = (1 << 63) - 1
16
+
17
+
18
+ def parse_amount(s: str) -> int:
19
+ """Parses an amount with its unit into uaeth."""
20
+ m = _PATTERN.match(s)
21
+ if not m:
22
+ if re.fullmatch(r"\s*[0-9]+\s*", s):
23
+ raise ValueError(f'amount "{s}" has no unit: write e.g. "1.5 AETH" or "1500000uaeth" (1 AETH = 1,000,000 uaeth)')
24
+ raise ValueError(f'invalid amount "{s}": write e.g. "1.5 AETH" or "1500000uaeth"')
25
+ whole, frac, unit = m.group(1), m.group(2) or "", m.group(3).lower()
26
+ if unit == "aeth":
27
+ if len(frac) > _DECIMALS:
28
+ raise ValueError(f'amount "{s}" has more than {_DECIMALS} decimal places')
29
+ v = int(whole + frac.ljust(_DECIMALS, "0"), 10)
30
+ elif unit == DENOM:
31
+ if frac:
32
+ raise ValueError(f'amount "{s}": uaeth can\'t be fractional')
33
+ v = int(whole, 10)
34
+ else:
35
+ raise ValueError(f'amount "{s}" has unknown unit "{m.group(3)}": use AETH or uaeth')
36
+ if v <= 0:
37
+ raise ValueError(f'amount "{s}" must be greater than zero')
38
+ if v > _MAX:
39
+ raise ValueError(f'amount "{s}" is too large')
40
+ return v
41
+
42
+
43
+ def parse_uaeth(s: str) -> int:
44
+ """A strictly base-10 whole number of uaeth ("1500000"), as wire formats carry it."""
45
+ if not re.fullmatch(r"[0-9]+", s or ""):
46
+ raise ValueError(f'invalid uaeth amount "{s}"')
47
+ return int(s, 10)
48
+
49
+
50
+ def format_aeth(uaeth: int) -> str:
51
+ """1500000 -> "1.5"."""
52
+ sign = "-" if uaeth < 0 else ""
53
+ a = abs(uaeth)
54
+ frac = str(a % 1_000_000).rjust(_DECIMALS, "0").rstrip("0")
55
+ return sign + str(a // 1_000_000) + ("." + frac if frac else "")
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Asset:
60
+ """A token the client knows by name. Amounts name their asset by unit ("5 USDC", "1.5 AETH",
61
+ "5000000uusdc"), so a USDC amount can never be read as AETH or the other way round."""
62
+ symbol: str # what people write: "AETH", "USDC"
63
+ denom: str # the chain's name for it: "uaeth", or "ibc/<hash>" for a token that arrived over IBC
64
+ base_unit: str # the smallest unit's name, which people may also write: "uaeth", "uusdc"
65
+ decimals: int # how many base units make one symbol, as a power of ten
66
+ origin: str # "Aether"; for USDC, its issuer and route, like "Noble over transfer/channel-3"
67
+
68
+
69
+ AETH = Asset("AETH", DENOM, DENOM, _DECIMALS, "Aether")
70
+
71
+
72
+ DEFAULT_USDC_BASE_DENOM = "uusdc" # USDC's denom on Noble, which issues it
73
+
74
+ _HOP = re.compile(r"[a-zA-Z0-9._+\-#\[\]<>]{2,128}/channel-[0-9]+")
75
+ _BASE_DENOM = re.compile(r"[a-zA-Z][a-zA-Z0-9/:._-]{2,127}")
76
+
77
+
78
+ def usdc(channel: str) -> Asset:
79
+ """Noble's USDC as it exists on Aether after crossing channel, the Aether end of Aether's own
80
+ channel to Noble. It's usdc_at with a single hop and Noble's base denom."""
81
+ if not re.fullmatch(r"channel-[0-9]+", channel or ""):
82
+ raise ValueError(f'USDC channel "{channel}": want Aether\'s end of its channel to Noble, like channel-3')
83
+ return usdc_at(f"transfer/{channel}", DEFAULT_USDC_BASE_DENOM)
84
+
85
+
86
+ def usdc_at(path: str, base_denom: str) -> Asset:
87
+ """The USDC that reaches Aether along path, its ICS-20 denom trace as Aether records it (Aether's
88
+ own hop first), with base_denom its denom on the chain that issues it: "transfer/channel-1/
89
+ transfer/channel-4280" and "uusdc" for Noble's USDC through Osmosis, say. The same token reaching
90
+ Aether any other way has a different denom and isn't interchangeable with it, so it's never
91
+ accepted as USDC. base_denom is case-sensitive."""
92
+ hops = (path or "").split("/")
93
+ if not path or len(hops) % 2:
94
+ raise ValueError(f'USDC path "{path}": want port/channel hops from Aether\'s end, like transfer/channel-1/transfer/channel-4280')
95
+ for i in range(0, len(hops), 2):
96
+ hop = f"{hops[i]}/{hops[i + 1]}"
97
+ if not _HOP.fullmatch(hop):
98
+ raise ValueError(f'USDC path "{path}": hop "{hop}" isn\'t port/channel-N')
99
+ if (base_denom or "").startswith("ibc/") or not _BASE_DENOM.fullmatch(base_denom or ""):
100
+ raise ValueError(f'USDC base denom "{base_denom}": want its denom on the chain that issues it, like uusdc, not an ibc/ hash')
101
+ h = hashlib.sha256(f"{path}/{base_denom}".encode()).hexdigest().upper()
102
+ issuer = "Noble" if base_denom == DEFAULT_USDC_BASE_DENOM else base_denom
103
+ return Asset("USDC", f"ibc/{h}", "uusdc", 6, f"{issuer} over {path}")
104
+
105
+
106
+ def usdc_for(usdc_channel: Optional[str] = None, usdc_path: Optional[str] = None,
107
+ usdc_base_denom: Optional[str] = None, usdc_issuer: Optional[str] = None) -> Optional[Asset]:
108
+ """Which USDC a client accepts: usdc_channel, as shorthand for Noble's USDC over Aether's direct
109
+ channel to Noble, or usdc_path and usdc_base_denom for any other route or issuer (usdc_base_denom
110
+ defaults to uusdc). None of them: no USDC. usdc_issuer names the issuer in the asset's origin,
111
+ which tools label USDC by ("USDC (Injective)"); default Noble for uusdc, else the base denom."""
112
+ u: Optional[Asset] = None
113
+ if usdc_channel and (usdc_path or usdc_base_denom):
114
+ raise ValueError("set either usdc_channel or usdc_path and usdc_base_denom, not both")
115
+ if usdc_channel:
116
+ u = usdc(usdc_channel)
117
+ elif usdc_path:
118
+ u = usdc_at(usdc_path, usdc_base_denom or DEFAULT_USDC_BASE_DENOM)
119
+ elif usdc_base_denom:
120
+ raise ValueError(f'USDC base denom "{usdc_base_denom}" needs usdc_path too')
121
+ if not usdc_issuer:
122
+ return u
123
+ if u is None:
124
+ raise ValueError(f'USDC issuer "{usdc_issuer}" needs usdc_channel or usdc_path too')
125
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,31}", usdc_issuer):
126
+ raise ValueError(f'USDC issuer "{usdc_issuer}": want one word, like Injective')
127
+ return Asset(u.symbol, u.denom, u.base_unit, u.decimals, f"{usdc_issuer} over {u.origin.split(' over ', 1)[1]}")
128
+
129
+
130
+ def decimal_of(asset: Asset, base: int) -> str:
131
+ """Base units as a decimal amount without trailing zeros or symbol: 1500000 -> "1.5"."""
132
+ sign = "-" if base < 0 else ""
133
+ a = abs(base)
134
+ if asset.decimals == 0:
135
+ return sign + str(a)
136
+ one = 10 ** asset.decimals
137
+ frac = str(a % one).rjust(asset.decimals, "0").rstrip("0")
138
+ return sign + str(a // one) + ("." + frac if frac else "")
139
+
140
+
141
+ def format_amount(asset: Asset, base: int) -> str:
142
+ """Base units with the asset's symbol: 1500000 -> "1.5 USDC"."""
143
+ return f"{decimal_of(asset, base)} {asset.symbol}"
144
+
145
+
146
+ def receipt_amount(amount: int, denom: str = DENOM) -> str:
147
+ """How a signed receipt states an amount: bare uaeth for AETH, else the amount followed by the denom."""
148
+ return str(amount) if denom in ("", DENOM) else f"{amount}{denom}"
149
+
150
+
151
+ class Assets:
152
+ """The assets a client accepts: AETH always, and the USDC its setting names, if any (see usdc_for)."""
153
+
154
+ def __init__(self, usdc_channel: Optional[str] = None, usdc_path: Optional[str] = None,
155
+ usdc_base_denom: Optional[str] = None, usdc_issuer: Optional[str] = None):
156
+ self._all: List[Asset] = [AETH]
157
+ u = usdc_for(usdc_channel, usdc_path, usdc_base_denom, usdc_issuer)
158
+ if u:
159
+ self._all.append(u)
160
+
161
+ def list(self) -> List[Asset]:
162
+ """Every asset, AETH first."""
163
+ return list(self._all)
164
+
165
+ def by_symbol(self, symbol: str) -> Optional[Asset]:
166
+ return next((a for a in self._all if a.symbol.lower() == symbol.lower()), None)
167
+
168
+ def by_denom(self, denom: str) -> Optional[Asset]:
169
+ return next((a for a in self._all if a.denom == denom), None)
170
+
171
+ def parse(self, s: str) -> Tuple[Asset, int]:
172
+ """Reads an amount with its unit: a symbol with up to its decimals ("1.5 AETH", "2.25usdc")
173
+ or a whole number of base units ("2250000 uusdc"). A bare number, or a unit it doesn't know,
174
+ is refused. Returns the asset and the amount in its base units."""
175
+ m = _PATTERN.match(s)
176
+ if not m:
177
+ if re.fullmatch(r"\s*[0-9]+(\.[0-9]+)?\s*", s):
178
+ raise ValueError(f'amount "{s}" has no unit: write e.g. "1.5 AETH" or "1500000uaeth"')
179
+ raise ValueError(f'invalid amount "{s}": write e.g. "1.5 AETH" or "1500000uaeth"')
180
+ whole, frac, unit = m.group(1), m.group(2) or "", m.group(3).lower()
181
+ for a in self._all:
182
+ if unit == a.symbol.lower():
183
+ if len(frac) > a.decimals:
184
+ raise ValueError(f'amount "{s}" has more than {a.decimals} decimal places')
185
+ v = int(whole + frac.ljust(a.decimals, "0"), 10)
186
+ elif unit == a.base_unit.lower():
187
+ if frac:
188
+ raise ValueError(f'amount "{s}": {a.base_unit} is the smallest unit and can\'t be fractional')
189
+ v = int(whole, 10)
190
+ else:
191
+ continue
192
+ if v <= 0:
193
+ raise ValueError(f'amount "{s}" must be greater than zero')
194
+ if v > _MAX:
195
+ raise ValueError(f'amount "{s}" is too large')
196
+ return a, v
197
+ units = ", ".join(u for a in self._all for u in (a.symbol, a.base_unit))
198
+ raise ValueError(f'amount "{s}" has unknown unit "{m.group(3)}": use {units}')
@@ -0,0 +1,64 @@
1
+ """Bech32 (BIP-173), as Cosmos addresses use it."""
2
+
3
+ CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
4
+ _GEN = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
5
+
6
+
7
+ def _polymod(values):
8
+ chk = 1
9
+ for v in values:
10
+ top = chk >> 25
11
+ chk = (chk & 0x1FFFFFF) << 5 ^ v
12
+ for i in range(5):
13
+ chk ^= _GEN[i] if (top >> i) & 1 else 0
14
+ return chk
15
+
16
+
17
+ def _hrp_expand(hrp):
18
+ return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
19
+
20
+
21
+ def _convertbits(data, frombits, tobits, pad):
22
+ acc = bits = 0
23
+ out = []
24
+ maxv = (1 << tobits) - 1
25
+ for value in data:
26
+ if value < 0 or value >> frombits:
27
+ raise ValueError("bech32: invalid value")
28
+ acc = (acc << frombits) | value
29
+ bits += frombits
30
+ while bits >= tobits:
31
+ bits -= tobits
32
+ out.append((acc >> bits) & maxv)
33
+ if pad:
34
+ if bits:
35
+ out.append((acc << (tobits - bits)) & maxv)
36
+ elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
37
+ raise ValueError("bech32: invalid padding")
38
+ return out
39
+
40
+
41
+ def encode(hrp: str, data: bytes) -> str:
42
+ words = _convertbits(data, 8, 5, True)
43
+ values = _hrp_expand(hrp) + words
44
+ polymod = _polymod(values + [0] * 6) ^ 1
45
+ checksum = [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
46
+ return hrp + "1" + "".join(CHARSET[d] for d in words + checksum)
47
+
48
+
49
+ def decode(s: str):
50
+ """Returns (hrp, data bytes); raises ValueError if invalid."""
51
+ if s.lower() != s and s.upper() != s:
52
+ raise ValueError("bech32: mixed case")
53
+ s = s.lower()
54
+ pos = s.rfind("1")
55
+ if pos < 1 or pos + 7 > len(s):
56
+ raise ValueError("bech32: bad separator")
57
+ hrp, rest = s[:pos], s[pos + 1:]
58
+ try:
59
+ values = [CHARSET.index(c) for c in rest]
60
+ except ValueError:
61
+ raise ValueError("bech32: invalid character") from None
62
+ if _polymod(_hrp_expand(hrp) + values) != 1:
63
+ raise ValueError("bech32: bad checksum")
64
+ return hrp, bytes(_convertbits(values[:-6], 5, 8, False))