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 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
+ ```
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"}