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.
- aether_chain_client-0.2.9/PKG-INFO +76 -0
- aether_chain_client-0.2.9/README.md +58 -0
- aether_chain_client-0.2.9/aether_chain_client.egg-info/PKG-INFO +76 -0
- aether_chain_client-0.2.9/aether_chain_client.egg-info/SOURCES.txt +27 -0
- aether_chain_client-0.2.9/aether_chain_client.egg-info/dependency_links.txt +1 -0
- aether_chain_client-0.2.9/aether_chain_client.egg-info/requires.txt +2 -0
- aether_chain_client-0.2.9/aether_chain_client.egg-info/top_level.txt +1 -0
- aether_chain_client-0.2.9/aether_client/__init__.py +19 -0
- aether_chain_client-0.2.9/aether_client/amount.py +198 -0
- aether_chain_client-0.2.9/aether_client/bech32.py +64 -0
- aether_chain_client-0.2.9/aether_client/client.py +284 -0
- aether_chain_client-0.2.9/aether_client/directory.py +200 -0
- aether_chain_client-0.2.9/aether_client/keys.py +77 -0
- aether_chain_client-0.2.9/aether_client/ledger.py +316 -0
- aether_chain_client-0.2.9/aether_client/paywall.py +386 -0
- aether_chain_client-0.2.9/aether_client/proto.py +92 -0
- aether_chain_client-0.2.9/aether_client/receipt.py +159 -0
- aether_chain_client-0.2.9/aether_client/rpc.py +77 -0
- aether_chain_client-0.2.9/aether_client/seller.py +1060 -0
- aether_chain_client-0.2.9/aether_client/tx.py +103 -0
- aether_chain_client-0.2.9/aether_client/withdraw.py +90 -0
- aether_chain_client-0.2.9/pyproject.toml +27 -0
- aether_chain_client-0.2.9/setup.cfg +4 -0
- aether_chain_client-0.2.9/tests/test_contract.py +28 -0
- aether_chain_client-0.2.9/tests/test_pull.py +192 -0
- aether_chain_client-0.2.9/tests/test_reputation.py +60 -0
- aether_chain_client-0.2.9/tests/test_seller.py +458 -0
- aether_chain_client-0.2.9/tests/test_usdc.py +217 -0
- 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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aether_client
|
|
@@ -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))
|