x402aff 0.1.0__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.
- x402aff-0.1.0/LICENSE +21 -0
- x402aff-0.1.0/PKG-INFO +100 -0
- x402aff-0.1.0/README.md +71 -0
- x402aff-0.1.0/pyproject.toml +50 -0
- x402aff-0.1.0/setup.cfg +4 -0
- x402aff-0.1.0/tests/test_affiliation.py +219 -0
- x402aff-0.1.0/tests/test_builder_code.py +134 -0
- x402aff-0.1.0/tests/test_payto.py +167 -0
- x402aff-0.1.0/tests/test_push_split.py +104 -0
- x402aff-0.1.0/tests/test_split.py +128 -0
- x402aff-0.1.0/x402aff/__init__.py +47 -0
- x402aff-0.1.0/x402aff/affiliation.py +275 -0
- x402aff-0.1.0/x402aff/builder_code.py +185 -0
- x402aff-0.1.0/x402aff/buyer_client.py +77 -0
- x402aff-0.1.0/x402aff/cdp_sql.py +98 -0
- x402aff-0.1.0/x402aff/distribute.py +135 -0
- x402aff-0.1.0/x402aff/monitor.py +136 -0
- x402aff-0.1.0/x402aff/payto.py +172 -0
- x402aff-0.1.0/x402aff/push_split.py +147 -0
- x402aff-0.1.0/x402aff/py.typed +0 -0
- x402aff-0.1.0/x402aff/queries.sql +149 -0
- x402aff-0.1.0/x402aff/resolver.py +131 -0
- x402aff-0.1.0/x402aff/split.py +163 -0
- x402aff-0.1.0/x402aff.egg-info/PKG-INFO +100 -0
- x402aff-0.1.0/x402aff.egg-info/SOURCES.txt +26 -0
- x402aff-0.1.0/x402aff.egg-info/dependency_links.txt +1 -0
- x402aff-0.1.0/x402aff.egg-info/requires.txt +8 -0
- x402aff-0.1.0/x402aff.egg-info/top_level.txt +1 -0
x402aff-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aeon Inc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
x402aff-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: x402aff
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Builder-code affiliation for x402 sellers - route each payment to a per-(seller, builder) 0xSplits split.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/MiroShark/x402aff
|
|
7
|
+
Project-URL: Repository, https://github.com/MiroShark/x402aff
|
|
8
|
+
Project-URL: Documentation, https://github.com/MiroShark/x402aff/blob/main/docs/INTEGRATION.md
|
|
9
|
+
Project-URL: Issues, https://github.com/MiroShark/x402aff/issues
|
|
10
|
+
Keywords: x402,builder-codes,0xsplits,base,affiliation
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: requests>=2.28
|
|
23
|
+
Requires-Dist: cbor2>=5.4
|
|
24
|
+
Provides-Extra: cdp
|
|
25
|
+
Requires-Dist: cdp-sdk>=1.0; extra == "cdp"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# x402aff - Python
|
|
31
|
+
|
|
32
|
+
The x402aff kit (Python): an installable `x402aff` package with the one-object
|
|
33
|
+
[`Affiliation`](./x402aff/affiliation.py) facade on top. This is the
|
|
34
|
+
implementation validated end-to-end on Base mainnet.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install x402aff # from PyPI
|
|
40
|
+
pip install 'x402aff[cdp]' # with the CDP discovery extra
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or from a checkout / straight from git, to track unreleased changes:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install ./python # from a clone, at the repo root
|
|
47
|
+
pip install '.[cdp]' # from inside python/, with the CDP extra
|
|
48
|
+
pip install 'x402aff[cdp] @ git+https://github.com/MiroShark/x402aff@main#subdirectory=python'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The `cdp` extra adds `cdp-sdk`, needed only for the CDP-index discovery path
|
|
52
|
+
(`monitor`, `aff.scan()` / `pending()` / `splits_payload()`). Without it the
|
|
53
|
+
request-time `payTo` and payout-calldata paths work fine.
|
|
54
|
+
|
|
55
|
+
Vendoring instead of installing works too - copy `python/x402aff/` into your
|
|
56
|
+
project as a subpackage; the modules import each other relatively, so nothing
|
|
57
|
+
needs rewriting and none of the generic names (`split`, `monitor`, `resolver`)
|
|
58
|
+
leak into your top-level namespace.
|
|
59
|
+
|
|
60
|
+
## Use
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from x402aff import Affiliation
|
|
64
|
+
|
|
65
|
+
aff = Affiliation(app_code="bc_yourcode", seller_payout="0xYourWallet")
|
|
66
|
+
|
|
67
|
+
# on your x402 route:
|
|
68
|
+
# pay_to=aff.pay_to per-request split, from the X-Builder-Code header
|
|
69
|
+
# extensions=aff.extensions declares your app code `a`
|
|
70
|
+
|
|
71
|
+
# release payouts, later (permissionless):
|
|
72
|
+
calls, balance = aff.release("bc_alice")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Full integration guide (money path, trust model, wiring, caveats):
|
|
76
|
+
[`../docs/INTEGRATION.md`](../docs/INTEGRATION.md). The TypeScript port lives in
|
|
77
|
+
[`../ts/`](../ts) and resolves the identical split address.
|
|
78
|
+
|
|
79
|
+
## Modules
|
|
80
|
+
|
|
81
|
+
| File | Role |
|
|
82
|
+
|------|------|
|
|
83
|
+
| `__init__.py` | The package's public surface (`from x402aff import Affiliation`, …). |
|
|
84
|
+
| `affiliation.py` | **Start here.** The `Affiliation` facade wrapping everything below. |
|
|
85
|
+
| `builder_code.py` | Declare `a`; decode `a`/`s`/`w` off a settle tx. |
|
|
86
|
+
| `resolver.py` | Builder code → registered payout address (one `eth_call`). |
|
|
87
|
+
| `split.py` | Builder code + price → the split plan (recipients + bps). |
|
|
88
|
+
| `push_split.py` | The plan → 0xSplits calldata + counterfactual address. |
|
|
89
|
+
| `payto.py` | Request-time `payTo` resolver. Cached, never raises. |
|
|
90
|
+
| `distribute.py` | The (deploy + distribute) calldata to release a funded split. |
|
|
91
|
+
| `monitor.py` | Which splits hold distributable funds (via CDP's index). |
|
|
92
|
+
| `buyer_client.py` | Buyer-side extension that attaches `s` (via `marked_service_codes`). |
|
|
93
|
+
| `cdp_sql.py` · `queries.sql` | CDP SQL API client + attribution queries. |
|
|
94
|
+
|
|
95
|
+
## Develop
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install -e '.[cdp,dev]' # editable install with the CDP + test extras
|
|
99
|
+
pytest # runs tests/ against the x402aff package
|
|
100
|
+
```
|
x402aff-0.1.0/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# x402aff - Python
|
|
2
|
+
|
|
3
|
+
The x402aff kit (Python): an installable `x402aff` package with the one-object
|
|
4
|
+
[`Affiliation`](./x402aff/affiliation.py) facade on top. This is the
|
|
5
|
+
implementation validated end-to-end on Base mainnet.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install x402aff # from PyPI
|
|
11
|
+
pip install 'x402aff[cdp]' # with the CDP discovery extra
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or from a checkout / straight from git, to track unreleased changes:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install ./python # from a clone, at the repo root
|
|
18
|
+
pip install '.[cdp]' # from inside python/, with the CDP extra
|
|
19
|
+
pip install 'x402aff[cdp] @ git+https://github.com/MiroShark/x402aff@main#subdirectory=python'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The `cdp` extra adds `cdp-sdk`, needed only for the CDP-index discovery path
|
|
23
|
+
(`monitor`, `aff.scan()` / `pending()` / `splits_payload()`). Without it the
|
|
24
|
+
request-time `payTo` and payout-calldata paths work fine.
|
|
25
|
+
|
|
26
|
+
Vendoring instead of installing works too - copy `python/x402aff/` into your
|
|
27
|
+
project as a subpackage; the modules import each other relatively, so nothing
|
|
28
|
+
needs rewriting and none of the generic names (`split`, `monitor`, `resolver`)
|
|
29
|
+
leak into your top-level namespace.
|
|
30
|
+
|
|
31
|
+
## Use
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from x402aff import Affiliation
|
|
35
|
+
|
|
36
|
+
aff = Affiliation(app_code="bc_yourcode", seller_payout="0xYourWallet")
|
|
37
|
+
|
|
38
|
+
# on your x402 route:
|
|
39
|
+
# pay_to=aff.pay_to per-request split, from the X-Builder-Code header
|
|
40
|
+
# extensions=aff.extensions declares your app code `a`
|
|
41
|
+
|
|
42
|
+
# release payouts, later (permissionless):
|
|
43
|
+
calls, balance = aff.release("bc_alice")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Full integration guide (money path, trust model, wiring, caveats):
|
|
47
|
+
[`../docs/INTEGRATION.md`](../docs/INTEGRATION.md). The TypeScript port lives in
|
|
48
|
+
[`../ts/`](../ts) and resolves the identical split address.
|
|
49
|
+
|
|
50
|
+
## Modules
|
|
51
|
+
|
|
52
|
+
| File | Role |
|
|
53
|
+
|------|------|
|
|
54
|
+
| `__init__.py` | The package's public surface (`from x402aff import Affiliation`, …). |
|
|
55
|
+
| `affiliation.py` | **Start here.** The `Affiliation` facade wrapping everything below. |
|
|
56
|
+
| `builder_code.py` | Declare `a`; decode `a`/`s`/`w` off a settle tx. |
|
|
57
|
+
| `resolver.py` | Builder code → registered payout address (one `eth_call`). |
|
|
58
|
+
| `split.py` | Builder code + price → the split plan (recipients + bps). |
|
|
59
|
+
| `push_split.py` | The plan → 0xSplits calldata + counterfactual address. |
|
|
60
|
+
| `payto.py` | Request-time `payTo` resolver. Cached, never raises. |
|
|
61
|
+
| `distribute.py` | The (deploy + distribute) calldata to release a funded split. |
|
|
62
|
+
| `monitor.py` | Which splits hold distributable funds (via CDP's index). |
|
|
63
|
+
| `buyer_client.py` | Buyer-side extension that attaches `s` (via `marked_service_codes`). |
|
|
64
|
+
| `cdp_sql.py` · `queries.sql` | CDP SQL API client + attribution queries. |
|
|
65
|
+
|
|
66
|
+
## Develop
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install -e '.[cdp,dev]' # editable install with the CDP + test extras
|
|
70
|
+
pytest # runs tests/ against the x402aff package
|
|
71
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "x402aff"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Builder-code affiliation for x402 sellers - route each payment to a per-(seller, builder) 0xSplits split."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["x402", "builder-codes", "0xsplits", "base", "affiliation"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Topic :: Office/Business :: Financial",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"requests>=2.28",
|
|
25
|
+
"cbor2>=5.4",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
# Discovery via the CDP SQL API: monitor.py, Affiliation.scan/pending/splits_payload.
|
|
30
|
+
cdp = ["cdp-sdk>=1.0"]
|
|
31
|
+
dev = ["pytest>=7.0"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/MiroShark/x402aff"
|
|
35
|
+
Repository = "https://github.com/MiroShark/x402aff"
|
|
36
|
+
Documentation = "https://github.com/MiroShark/x402aff/blob/main/docs/INTEGRATION.md"
|
|
37
|
+
Issues = "https://github.com/MiroShark/x402aff/issues"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools]
|
|
40
|
+
packages = ["x402aff"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.package-data]
|
|
43
|
+
# Ship the reference queries alongside the code they document, and the PEP 561
|
|
44
|
+
# marker so a consumer's type checker sees the annotations.
|
|
45
|
+
x402aff = ["queries.sql", "py.typed"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
pythonpath = ["."]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
addopts = "-q"
|
x402aff-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Tests for the Affiliation facade (affiliation.py) - no network.
|
|
2
|
+
|
|
3
|
+
Resolution is monkeypatched so these run offline; they check the facade wires the
|
|
4
|
+
underlying modules together correctly and keeps its safe-fallback contract.
|
|
5
|
+
|
|
6
|
+
pytest test_affiliation.py -q
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from x402aff import distribute, push_split, resolver
|
|
15
|
+
from x402aff.affiliation import Affiliation
|
|
16
|
+
from x402aff.builder_code import declare_builder_code
|
|
17
|
+
|
|
18
|
+
SELLER = "0x2222222222222222222222222222222222222222"
|
|
19
|
+
BUILDER = "0x1111111111111111111111111111111111111111"
|
|
20
|
+
SPLIT = "0x3773000000000000000000000000000000002e38"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _FakeAdapter:
|
|
24
|
+
def __init__(self, value):
|
|
25
|
+
self._value = value
|
|
26
|
+
|
|
27
|
+
def get_header(self, name):
|
|
28
|
+
return self._value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _FakeCtx:
|
|
32
|
+
"""Looks like an x402 HTTPRequestContext for header extraction."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, value):
|
|
35
|
+
self.adapter = _FakeAdapter(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _aff(**kw) -> Affiliation:
|
|
39
|
+
Affiliation.clear_cache() # payto._CACHE is process-global; isolate each test
|
|
40
|
+
return Affiliation(app_code="bc_seller", seller_payout=SELLER, **kw)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── construction ──────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def test_requires_app_code_and_seller():
|
|
46
|
+
import pytest
|
|
47
|
+
|
|
48
|
+
with pytest.raises(ValueError):
|
|
49
|
+
Affiliation(app_code="", seller_payout=SELLER)
|
|
50
|
+
with pytest.raises(ValueError):
|
|
51
|
+
Affiliation(app_code="bc_seller", seller_payout="")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_extensions_declares_app_code():
|
|
55
|
+
aff = _aff()
|
|
56
|
+
assert aff.extensions == declare_builder_code("bc_seller")
|
|
57
|
+
assert aff.extensions["builder-code"]["info"]["a"] == "bc_seller"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_custom_share_sets_effective_share():
|
|
61
|
+
assert _aff()._share == push_split.BUILDER_SHARE_BPS
|
|
62
|
+
assert _aff(builder_share_bps=2500)._share == 2500
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── code extraction from every source shape ───────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def test_code_from_raw_string_takes_primary():
|
|
68
|
+
assert _aff()._code_from("bc_alice,bc_bob") == "bc_alice"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_code_from_headers_mapping():
|
|
72
|
+
aff = _aff()
|
|
73
|
+
assert aff._code_from({Affiliation.HEADER: "bc_alice"}) == "bc_alice"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_code_from_x402_context():
|
|
77
|
+
aff = _aff()
|
|
78
|
+
assert aff._code_from(_FakeCtx("bc_alice")) == "bc_alice"
|
|
79
|
+
assert aff._code_from(_FakeCtx(None)) is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_code_from_none():
|
|
83
|
+
assert _aff()._code_from(None) is None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── safe fallback: no/empty code → seller, no network ─────────────────────────
|
|
87
|
+
|
|
88
|
+
def test_no_code_falls_back_to_seller():
|
|
89
|
+
aff = _aff()
|
|
90
|
+
pt = aff.resolve(None)
|
|
91
|
+
assert pt.address == SELLER
|
|
92
|
+
assert pt.attributed is False
|
|
93
|
+
assert aff.pay_to_for(None) == SELLER
|
|
94
|
+
assert aff.pay_to_for({Affiliation.HEADER: ""}) == SELLER
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_pay_to_async_callback_no_code():
|
|
98
|
+
aff = _aff()
|
|
99
|
+
addr = asyncio.run(aff.pay_to(_FakeCtx(None)))
|
|
100
|
+
assert addr == SELLER
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── attributed path (resolution monkeypatched) ────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def _patch_registered(monkeypatch, *, deployed=False, balance=0):
|
|
106
|
+
monkeypatch.setattr(
|
|
107
|
+
resolver, "resolve",
|
|
108
|
+
lambda code, **kw: {"code": code, "registered": True,
|
|
109
|
+
"owner": BUILDER, "payout_address": BUILDER},
|
|
110
|
+
)
|
|
111
|
+
monkeypatch.setattr(
|
|
112
|
+
push_split, "predict_split_address",
|
|
113
|
+
lambda plan, **kw: (SPLIT, deployed),
|
|
114
|
+
)
|
|
115
|
+
monkeypatch.setattr(
|
|
116
|
+
distribute, "split_balance_units",
|
|
117
|
+
lambda addr, **kw: balance,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_resolve_registered_code_routes_to_split(monkeypatch):
|
|
122
|
+
_patch_registered(monkeypatch)
|
|
123
|
+
aff = _aff()
|
|
124
|
+
pt = aff.resolve(_FakeCtx("bc_alice"))
|
|
125
|
+
assert pt.address == SPLIT
|
|
126
|
+
assert pt.attributed is True
|
|
127
|
+
# 10% builder / 90% seller in the plan
|
|
128
|
+
assert pt.plan.has_builder is True
|
|
129
|
+
assert dict(pt.plan.recipients)[BUILDER] == push_split.BUILDER_SHARE_BPS
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_pay_to_async_callback_registered(monkeypatch):
|
|
133
|
+
_patch_registered(monkeypatch)
|
|
134
|
+
aff = _aff()
|
|
135
|
+
addr = asyncio.run(aff.pay_to(_FakeCtx("bc_alice")))
|
|
136
|
+
assert addr == SPLIT
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_balance_reads_split(monkeypatch):
|
|
140
|
+
_patch_registered(monkeypatch, balance=12_400_000)
|
|
141
|
+
assert _aff().balance("bc_alice") == 12_400_000
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_balance_zero_when_unattributed(monkeypatch):
|
|
145
|
+
monkeypatch.setattr(resolver, "resolve",
|
|
146
|
+
lambda code, **kw: {"registered": False, "payout_address": None})
|
|
147
|
+
assert _aff().balance("bc_alice") == 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_release_builds_deploy_and_distribute(monkeypatch):
|
|
151
|
+
_patch_registered(monkeypatch, deployed=False, balance=12_400_000)
|
|
152
|
+
calls, balance = _aff().release("bc_alice")
|
|
153
|
+
assert balance == 12_400_000
|
|
154
|
+
steps = [c.step for c in calls]
|
|
155
|
+
assert steps == ["deploy_split", "distribute"] # not deployed → both legs
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_release_skips_deploy_when_already_deployed(monkeypatch):
|
|
159
|
+
_patch_registered(monkeypatch, deployed=True, balance=5_000_000)
|
|
160
|
+
calls, _ = _aff().release("bc_alice")
|
|
161
|
+
assert [c.step for c in calls] == ["distribute"]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_splits_payload_shapes_rows_and_filters_marker(monkeypatch):
|
|
165
|
+
from x402aff import monitor
|
|
166
|
+
|
|
167
|
+
_patch_registered(monkeypatch, deployed=False, balance=1_000_000)
|
|
168
|
+
# Discovery is CDP-backed; stub it. The shared marker rides along as a
|
|
169
|
+
# "builder" and must be dropped; the app code + facilitator are already
|
|
170
|
+
# filtered by discover_builder_codes.
|
|
171
|
+
monkeypatch.setattr(monitor, "discover_builder_codes",
|
|
172
|
+
lambda app_code, **kw: ["bc_alice", "x402aff"])
|
|
173
|
+
|
|
174
|
+
payload = _aff().splits_payload()
|
|
175
|
+
assert payload["configured"] is True
|
|
176
|
+
assert payload["marker"] == "x402aff"
|
|
177
|
+
assert payload["count"] == 1 # the marker row is dropped
|
|
178
|
+
s = payload["splits"][0]
|
|
179
|
+
assert s["payTo"] == SPLIT
|
|
180
|
+
assert s["sellerCode"] == "bc_seller"
|
|
181
|
+
assert s["builderCode"] == "bc_alice"
|
|
182
|
+
assert s["balanceUnits"] == "1000000"
|
|
183
|
+
assert s["deployed"] is False
|
|
184
|
+
assert s["claimable"] is True
|
|
185
|
+
assert [c["step"] for c in s["calls"]] == ["deploy_split", "distribute"]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_splits_payload_makes_no_base_events_query(monkeypatch):
|
|
189
|
+
"""Regression: the per-split payments/received rollup joined base.events and
|
|
190
|
+
tripped the CDP SQL API's leaf-scan limit (400) on every call - see
|
|
191
|
+
queries.sql #5b. splits_payload must issue no such query, and must not
|
|
192
|
+
resurrect the fields that only that query could populate."""
|
|
193
|
+
from x402aff import monitor
|
|
194
|
+
|
|
195
|
+
_patch_registered(monkeypatch, deployed=False, balance=1_000_000)
|
|
196
|
+
monkeypatch.setattr(monitor, "discover_builder_codes",
|
|
197
|
+
lambda app_code, **kw: ["bc_alice"])
|
|
198
|
+
# Any CDP SQL beyond the stubbed discovery is a reintroduced rollup.
|
|
199
|
+
monkeypatch.setattr(monitor.cdp_sql, "run_query",
|
|
200
|
+
lambda *a, **kw: pytest.fail("splits_payload ran an unexpected CDP query"))
|
|
201
|
+
|
|
202
|
+
s = _aff().splits_payload()["splits"][0]
|
|
203
|
+
assert "payments" not in s
|
|
204
|
+
assert "receivedUnits" not in s
|
|
205
|
+
assert not hasattr(monitor, "discover_split_rollup")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_resolve_failure_falls_back_and_logs(monkeypatch, caplog):
|
|
209
|
+
def _boom(code, **kw):
|
|
210
|
+
raise RuntimeError("RPC 429")
|
|
211
|
+
|
|
212
|
+
monkeypatch.setattr(resolver, "resolve", _boom)
|
|
213
|
+
aff = _aff()
|
|
214
|
+
with caplog.at_level("WARNING", logger="affiliation"):
|
|
215
|
+
pt = aff.resolve(_FakeCtx("bc_alice"))
|
|
216
|
+
assert pt.address == SELLER # never raises - payment still works
|
|
217
|
+
assert pt.attributed is False
|
|
218
|
+
assert pt.error is not None
|
|
219
|
+
assert any("resolve failed" in r.message for r in caplog.records)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Tests for the declare + decode helpers.
|
|
2
|
+
|
|
3
|
+
Run: pip install cbor2 pytest && pytest test_builder_code.py
|
|
4
|
+
|
|
5
|
+
These prove the wire shape and the on-chain suffix round-trip. They do NOT prove
|
|
6
|
+
real on-chain attribution - that needs a real Base-mainnet settlement verified
|
|
7
|
+
against the calldata suffix (see README → Verify).
|
|
8
|
+
"""
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from x402aff.builder_code import (
|
|
12
|
+
AFFILIATION_MARKER,
|
|
13
|
+
BUILDER_CODE_KEY,
|
|
14
|
+
ERC_8021_MARKER,
|
|
15
|
+
declare_builder_code,
|
|
16
|
+
marked_service_codes,
|
|
17
|
+
normalize_builder_code,
|
|
18
|
+
normalize_service_codes,
|
|
19
|
+
parse_builder_code_suffix,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# --- declare_builder_code: the "declare it" step ------------------------------
|
|
23
|
+
|
|
24
|
+
def test_declares_app_code_under_correct_key():
|
|
25
|
+
ext = declare_builder_code("bc_yourcode")
|
|
26
|
+
assert set(ext) == {BUILDER_CODE_KEY}
|
|
27
|
+
assert ext[BUILDER_CODE_KEY]["info"] == {"a": "bc_yourcode"}
|
|
28
|
+
schema = ext[BUILDER_CODE_KEY]["schema"]
|
|
29
|
+
assert set(schema["properties"]) == {"a", "w", "s"}
|
|
30
|
+
assert schema["additionalProperties"] is False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_strips_whitespace():
|
|
34
|
+
assert declare_builder_code(" bc_yourcode ")[BUILDER_CODE_KEY]["info"]["a"] == "bc_yourcode"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.parametrize("bad", ["", "BC_UPPER", "bc-dash", "x" * 33, "bc code"])
|
|
38
|
+
def test_rejects_malformed_codes(bad):
|
|
39
|
+
with pytest.raises(ValueError):
|
|
40
|
+
declare_builder_code(bad)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- normalizers: sanitize codes read off the wire ----------------------------
|
|
44
|
+
|
|
45
|
+
def test_normalize_builder_code_accepts_and_trims():
|
|
46
|
+
assert normalize_builder_code(" bc_abc12345 ") == "bc_abc12345"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize("bad", ["", "BC_UPPER", "bc-dash", "x" * 33, None, 123, ["bc_x"]])
|
|
50
|
+
def test_normalize_builder_code_rejects(bad):
|
|
51
|
+
assert normalize_builder_code(bad) is None # never raises
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_normalize_service_codes_list_joins_valid():
|
|
55
|
+
assert normalize_service_codes(["bc_one", "bc_two"]) == "bc_one,bc_two"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_normalize_service_codes_accepts_bare_string():
|
|
59
|
+
assert normalize_service_codes("bc_abc12345") == "bc_abc12345"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_normalize_service_codes_dedupes_and_drops_invalid():
|
|
63
|
+
assert normalize_service_codes(["bc_one", "BC_BAD", "bc_one", "bc_two"]) == "bc_one,bc_two"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize("empty", [None, [], ["BAD!"], "not a code", [""]])
|
|
67
|
+
def test_normalize_service_codes_empty_to_none(empty):
|
|
68
|
+
assert normalize_service_codes(empty) is None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- marked_service_codes: the buyer-side `s` -------------------------------
|
|
72
|
+
|
|
73
|
+
def test_marked_service_codes_appends_the_shared_marker():
|
|
74
|
+
assert marked_service_codes("bc_yourcode") == ["bc_yourcode", AFFILIATION_MARKER]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_marked_service_codes_keeps_the_real_code_primary():
|
|
78
|
+
# The split pays the FIRST code, so the marker must never lead.
|
|
79
|
+
assert marked_service_codes("bc_yourcode")[0] == "bc_yourcode"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_marked_service_codes_never_duplicates_the_marker():
|
|
83
|
+
assert marked_service_codes(AFFILIATION_MARKER) == [AFFILIATION_MARKER]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_buyer_extension_stamps_exactly_marked_service_codes():
|
|
87
|
+
"""The extension must not re-implement the rule - one source of truth."""
|
|
88
|
+
from x402aff.buyer_client import BuilderCodeClientExtension
|
|
89
|
+
|
|
90
|
+
class _Payload:
|
|
91
|
+
extensions = None
|
|
92
|
+
|
|
93
|
+
def model_copy(self, *, update):
|
|
94
|
+
return update["extensions"]
|
|
95
|
+
|
|
96
|
+
stamped = BuilderCodeClientExtension("bc_yourcode").enrich_payment_payload(_Payload(), None)
|
|
97
|
+
assert stamped["builder-code"]["info"]["s"] == marked_service_codes("bc_yourcode")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --- parse_builder_code_suffix: the "recover w from chain" step ---------------
|
|
101
|
+
|
|
102
|
+
def _make_suffix(**codes) -> bytes:
|
|
103
|
+
"""Build a Schema 2 suffix the way the facilitator does."""
|
|
104
|
+
import cbor2
|
|
105
|
+
|
|
106
|
+
cbor = cbor2.dumps(codes)
|
|
107
|
+
return cbor + len(cbor).to_bytes(2, "big") + bytes([2]) + ERC_8021_MARKER
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_parse_recovers_app_and_wallet_codes():
|
|
111
|
+
suffix = _make_suffix(a="bc_yourcode", w="cdp_facil1")
|
|
112
|
+
calldata = "0x" + ("ab" * 120) + suffix.hex()
|
|
113
|
+
assert parse_builder_code_suffix(calldata) == {"a": "bc_yourcode", "w": "cdp_facil1"}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_parse_recovers_service_codes_array():
|
|
117
|
+
suffix = _make_suffix(s=["bc_client1", "bc_client2"])
|
|
118
|
+
assert parse_builder_code_suffix(("00" * 40) + suffix.hex()) == {"s": ["bc_client1", "bc_client2"]}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_parse_all_three_fields():
|
|
122
|
+
suffix = _make_suffix(a="bc_app", w="cdp_facil1", s=["bc_client"])
|
|
123
|
+
assert parse_builder_code_suffix("0x" + suffix.hex()) == {
|
|
124
|
+
"a": "bc_app", "w": "cdp_facil1", "s": ["bc_client"]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.parametrize("calldata", ["0xa9059cbb" + "00" * 60, "0x", "0xdeadbeef", "not hex", ""])
|
|
129
|
+
def test_parse_no_suffix_returns_none(calldata):
|
|
130
|
+
assert parse_builder_code_suffix(calldata) is None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_parse_accepts_raw_bytes():
|
|
134
|
+
assert parse_builder_code_suffix(b"\x00\x00" + _make_suffix(w="cdp_facil1")) == {"w": "cdp_facil1"}
|