x402aff 0.1.0__py3-none-any.whl
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/__init__.py +47 -0
- x402aff/affiliation.py +275 -0
- x402aff/builder_code.py +185 -0
- x402aff/buyer_client.py +77 -0
- x402aff/cdp_sql.py +98 -0
- x402aff/distribute.py +135 -0
- x402aff/monitor.py +136 -0
- x402aff/payto.py +172 -0
- x402aff/push_split.py +147 -0
- x402aff/py.typed +0 -0
- x402aff/queries.sql +149 -0
- x402aff/resolver.py +131 -0
- x402aff/split.py +163 -0
- x402aff-0.1.0.dist-info/METADATA +100 -0
- x402aff-0.1.0.dist-info/RECORD +18 -0
- x402aff-0.1.0.dist-info/WHEEL +5 -0
- x402aff-0.1.0.dist-info/licenses/LICENSE +21 -0
- x402aff-0.1.0.dist-info/top_level.txt +1 -0
x402aff/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""x402aff - builder-code affiliation for x402 sellers.
|
|
2
|
+
|
|
3
|
+
Route each payment to a per-(seller, builder) 0xSplits split so the builder who
|
|
4
|
+
drove it earns an enforced on-chain cut, with no ledger and no custody:
|
|
5
|
+
|
|
6
|
+
from x402aff import Affiliation
|
|
7
|
+
|
|
8
|
+
aff = Affiliation(app_code="bc_yourcode", seller_payout="0x…")
|
|
9
|
+
|
|
10
|
+
PaymentOption(..., pay_to=aff.pay_to) # per-request split address
|
|
11
|
+
RouteConfig(..., extensions=aff.extensions) # declares your `a`
|
|
12
|
+
|
|
13
|
+
`Affiliation` is the whole integration; the submodules below are the pieces it
|
|
14
|
+
sits on, importable directly if you need them (``from x402aff import payto``).
|
|
15
|
+
|
|
16
|
+
Buyers stamping their own builder code want `BuilderCodeClientExtension` from
|
|
17
|
+
``x402aff.buyer_client``; it is not re-exported here because it pulls in the
|
|
18
|
+
x402 client SDK, which a seller does not need.
|
|
19
|
+
|
|
20
|
+
Discovery (``monitor``, ``Affiliation.scan``/``pending``/``splits_payload``)
|
|
21
|
+
needs the optional CDP extra: ``pip install 'x402aff[cdp]'``.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from .affiliation import Affiliation
|
|
26
|
+
from .builder_code import (
|
|
27
|
+
AFFILIATION_MARKER,
|
|
28
|
+
declare_builder_code,
|
|
29
|
+
marked_service_codes,
|
|
30
|
+
normalize_service_codes,
|
|
31
|
+
parse_builder_code_suffix,
|
|
32
|
+
)
|
|
33
|
+
from .push_split import BUILDER_SHARE_BPS, USDC_BASE, predict_split_address
|
|
34
|
+
from .resolver import BUILDER_CODES_REGISTRY
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Affiliation",
|
|
38
|
+
"AFFILIATION_MARKER",
|
|
39
|
+
"BUILDER_CODES_REGISTRY",
|
|
40
|
+
"BUILDER_SHARE_BPS",
|
|
41
|
+
"USDC_BASE",
|
|
42
|
+
"declare_builder_code",
|
|
43
|
+
"marked_service_codes",
|
|
44
|
+
"normalize_service_codes",
|
|
45
|
+
"parse_builder_code_suffix",
|
|
46
|
+
"predict_split_address",
|
|
47
|
+
]
|
x402aff/affiliation.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""One object that is the whole integration - declare, payTo, distribute.
|
|
2
|
+
|
|
3
|
+
The x402aff kit's pieces (`builder_code`, `payto`, `split`, `push_split`, `distribute`,
|
|
4
|
+
`monitor`) each do one job well, but wiring an x402 route means importing several
|
|
5
|
+
and hand-rolling a ``payTo`` callback. This folds all of that behind a single
|
|
6
|
+
configured object so the integration is a couple of lines.
|
|
7
|
+
|
|
8
|
+
from x402aff import Affiliation
|
|
9
|
+
|
|
10
|
+
aff = Affiliation(app_code="bc_yourcode", seller_payout="0x…")
|
|
11
|
+
|
|
12
|
+
# ── the x402 route: two attributes, no boilerplate ──
|
|
13
|
+
PaymentOption(scheme="exact", price="$0.02", network="eip155:8453",
|
|
14
|
+
pay_to=aff.pay_to) # ← was a ~15-line DynamicPayTo function
|
|
15
|
+
RouteConfig(..., extensions=aff.extensions) # declares your `a`
|
|
16
|
+
|
|
17
|
+
# ── framework-agnostic (Flask/Django/etc.) if you're not using the callback ──
|
|
18
|
+
addr = aff.pay_to_for(request.headers) # sync → the split address (or your wallet)
|
|
19
|
+
|
|
20
|
+
# ── the payout side ──
|
|
21
|
+
for s in aff.pending(): # splits holding distributable funds
|
|
22
|
+
print(s.builder_code, s.distributable_units)
|
|
23
|
+
calls, balance = aff.release("bc_alice") # deploy (first use) + distribute calldata
|
|
24
|
+
|
|
25
|
+
``aff.pay_to`` is a drop-in x402 ``DynamicPayTo`` callback: it pulls the builder
|
|
26
|
+
code off the request, resolves the per-pair split, and - like everything here -
|
|
27
|
+
**never raises**; any failure falls back to the seller wallet (unsplit, never a
|
|
28
|
+
failed payment). The same object accepts a headers mapping or a raw code, so it
|
|
29
|
+
works outside the x402 SDK too.
|
|
30
|
+
|
|
31
|
+
Import is dependency-light (only the request-path modules). The payout helpers
|
|
32
|
+
(`pending`/`scan`) import `monitor` lazily, so you only need `cdp-sdk` if you
|
|
33
|
+
actually use CDP-index discovery.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import logging
|
|
38
|
+
from typing import Optional
|
|
39
|
+
|
|
40
|
+
from . import distribute, payto, push_split, resolver, split
|
|
41
|
+
from .builder_code import AFFILIATION_MARKER, declare_builder_code
|
|
42
|
+
|
|
43
|
+
log = logging.getLogger("affiliation")
|
|
44
|
+
|
|
45
|
+
__all__ = ["Affiliation"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Affiliation:
|
|
49
|
+
"""Configured entry point for one seller: declare ``a``, route ``payTo`` to
|
|
50
|
+
the per-builder split, and release funds.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
app_code
|
|
55
|
+
Your app / resource-server code (the ``a`` you declare on the route).
|
|
56
|
+
seller_payout
|
|
57
|
+
Where your 90% (and every unattributed payment) is paid.
|
|
58
|
+
builder_share_bps
|
|
59
|
+
The builder's cut in basis points (``1000`` = 10%). ``None`` uses
|
|
60
|
+
``X402_BUILDER_SHARE_BPS`` / the x402aff kit default.
|
|
61
|
+
rpc_url
|
|
62
|
+
Base RPC for the address/balance reads. ``None`` uses ``X402_BASE_RPC`` /
|
|
63
|
+
the public endpoint (which rate-limits - set a paid one in production).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
#: The request header a buyer's client sets to name its builder.
|
|
67
|
+
HEADER = payto.BUILDER_CODE_HEADER
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
app_code: str,
|
|
72
|
+
seller_payout: str,
|
|
73
|
+
*,
|
|
74
|
+
builder_share_bps: Optional[int] = None,
|
|
75
|
+
rpc_url: Optional[str] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
if not app_code:
|
|
78
|
+
raise ValueError("app_code is required (your `a` code)")
|
|
79
|
+
if not seller_payout:
|
|
80
|
+
raise ValueError("seller_payout is required")
|
|
81
|
+
self.app_code = app_code
|
|
82
|
+
self.seller_payout = seller_payout
|
|
83
|
+
self.builder_share_bps = builder_share_bps
|
|
84
|
+
self.rpc_url = rpc_url
|
|
85
|
+
# Effective share, resolved once so payTo and monitoring never disagree.
|
|
86
|
+
self._share = (
|
|
87
|
+
push_split.BUILDER_SHARE_BPS
|
|
88
|
+
if builder_share_bps is None
|
|
89
|
+
else builder_share_bps
|
|
90
|
+
)
|
|
91
|
+
self._extensions: Optional[dict] = None
|
|
92
|
+
|
|
93
|
+
# ── request path ─────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def extensions(self) -> dict:
|
|
97
|
+
"""The route ``extensions`` that declare your app code ``a``.
|
|
98
|
+
|
|
99
|
+
Merge into any extensions you already advertise:
|
|
100
|
+
``{**my_extensions, **aff.extensions}``.
|
|
101
|
+
"""
|
|
102
|
+
if self._extensions is None:
|
|
103
|
+
self._extensions = declare_builder_code(self.app_code)
|
|
104
|
+
return self._extensions
|
|
105
|
+
|
|
106
|
+
async def pay_to(self, ctx) -> str:
|
|
107
|
+
"""Drop-in x402 ``DynamicPayTo`` callback → the ``payTo`` for this request.
|
|
108
|
+
|
|
109
|
+
Pass as ``PaymentOption(pay_to=aff.pay_to)``. Reads ``X-Builder-Code`` off
|
|
110
|
+
the request context and returns the per-pair split address, or the seller
|
|
111
|
+
wallet when there's no/unknown/unresolvable code.
|
|
112
|
+
"""
|
|
113
|
+
return self.resolve(ctx).address
|
|
114
|
+
|
|
115
|
+
def pay_to_for(self, source=None, *, code: Optional[str] = None) -> str:
|
|
116
|
+
"""Sync ``payTo`` for a request - for stacks where you set it yourself.
|
|
117
|
+
|
|
118
|
+
``source`` may be an x402 request context, a headers mapping (Flask/
|
|
119
|
+
Starlette/dict), or a raw code string. Or pass ``code=`` directly.
|
|
120
|
+
"""
|
|
121
|
+
return self.resolve(source, code=code).address
|
|
122
|
+
|
|
123
|
+
def resolve(self, source=None, *, code: Optional[str] = None) -> payto.PayTo:
|
|
124
|
+
"""Full ``PayTo`` (address + why) for a request. Never raises.
|
|
125
|
+
|
|
126
|
+
Logs a warning if resolution *failed* (vs. simply finding no builder) -
|
|
127
|
+
a spike there means the RPC is rate-limiting and builders are silently
|
|
128
|
+
losing their cut.
|
|
129
|
+
"""
|
|
130
|
+
if code is None:
|
|
131
|
+
code = self._code_from(source)
|
|
132
|
+
pt = payto.payto_for_request(
|
|
133
|
+
code,
|
|
134
|
+
seller_payout=self.seller_payout,
|
|
135
|
+
builder_share_bps=self.builder_share_bps,
|
|
136
|
+
rpc_url=self.rpc_url,
|
|
137
|
+
)
|
|
138
|
+
if pt.error:
|
|
139
|
+
log.warning("payTo resolve failed for %r, unsplit: %s", code, pt.error)
|
|
140
|
+
return pt
|
|
141
|
+
|
|
142
|
+
def _code_from(self, source) -> Optional[str]:
|
|
143
|
+
"""Pull a normalized builder code out of whatever the framework hands us."""
|
|
144
|
+
if source is None:
|
|
145
|
+
return None
|
|
146
|
+
if isinstance(source, str):
|
|
147
|
+
return split.primary_code(source)
|
|
148
|
+
# x402 request context: ctx.adapter.get_header(name)
|
|
149
|
+
adapter = getattr(source, "adapter", None)
|
|
150
|
+
if adapter is not None and hasattr(adapter, "get_header"):
|
|
151
|
+
raw = adapter.get_header(self.HEADER)
|
|
152
|
+
return payto.builder_code_from_headers({self.HEADER: raw} if raw else {})
|
|
153
|
+
# headers-like mapping (has .get): Flask/Starlette request.headers, dict, …
|
|
154
|
+
if hasattr(source, "get"):
|
|
155
|
+
return payto.builder_code_from_headers(source)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def clear_cache() -> None:
|
|
160
|
+
"""Drop the memoized code→split-address cache (after a share change, or
|
|
161
|
+
to retry a resolve that failed on a rate-limited RPC)."""
|
|
162
|
+
payto.clear_cache()
|
|
163
|
+
|
|
164
|
+
# ── payout path ──────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
def balance(self, code: str) -> int:
|
|
167
|
+
"""USDC base units currently sitting in this builder's split (0 if none)."""
|
|
168
|
+
pt = self.resolve(code=code)
|
|
169
|
+
if not pt.attributed:
|
|
170
|
+
return 0
|
|
171
|
+
return distribute.split_balance_units(pt.address, rpc_url=self.rpc_url)
|
|
172
|
+
|
|
173
|
+
def release(self, code: str, *, distributor: Optional[str] = None):
|
|
174
|
+
"""Build the release calls + live balance for one builder's split.
|
|
175
|
+
|
|
176
|
+
Returns ``(calls, balance_units)`` - submit each ``(target, data)`` from
|
|
177
|
+
any funded Base account (skip when balance is 0). Permissionless.
|
|
178
|
+
"""
|
|
179
|
+
pt = self.resolve(code=code)
|
|
180
|
+
return distribute.distribute_plan(
|
|
181
|
+
pt.plan, rpc_url=self.rpc_url, distributor=distributor
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def scan(self, *, days: int = 90):
|
|
185
|
+
"""Every builder who paid you and their split's balance (fullest first).
|
|
186
|
+
|
|
187
|
+
Needs ``cdp-sdk`` - discovery uses CDP's attribution index (no local
|
|
188
|
+
ledger). Returns ``monitor.SplitStatus`` rows.
|
|
189
|
+
"""
|
|
190
|
+
from . import monitor # lazy: only this path needs cdp_sql / cdp-sdk
|
|
191
|
+
|
|
192
|
+
codes = monitor.discover_builder_codes(self.app_code, days=days)
|
|
193
|
+
rows = [self._status_for(c) for c in codes]
|
|
194
|
+
rows.sort(key=lambda s: s.balance_units, reverse=True)
|
|
195
|
+
return rows
|
|
196
|
+
|
|
197
|
+
def pending(self, *, days: int = 90):
|
|
198
|
+
"""The subset of :meth:`scan` whose splits hold distributable funds."""
|
|
199
|
+
return [s for s in self.scan(days=days) if s.needs_distribution]
|
|
200
|
+
|
|
201
|
+
def _status_for(self, code: str):
|
|
202
|
+
"""One builder's split status at THIS facade's share (keeps the predicted
|
|
203
|
+
address consistent with what :meth:`pay_to` advertises)."""
|
|
204
|
+
from . import monitor
|
|
205
|
+
|
|
206
|
+
info = resolver.resolve(code, rpc_url=self.rpc_url or push_split.BASE_RPC)
|
|
207
|
+
payout = info.get("payout_address") if info.get("registered") else None
|
|
208
|
+
if not payout:
|
|
209
|
+
return monitor.SplitStatus(code, None, None, False, 0)
|
|
210
|
+
plan = split.build_split_plan(
|
|
211
|
+
self.seller_payout, payout, builder_code=code, builder_share_bps=self._share
|
|
212
|
+
)
|
|
213
|
+
addr, deployed = push_split.predict_split_address(plan, rpc_url=self.rpc_url)
|
|
214
|
+
bal = distribute.split_balance_units(addr, rpc_url=self.rpc_url)
|
|
215
|
+
return monitor.SplitStatus(code, payout, addr, deployed, bal)
|
|
216
|
+
|
|
217
|
+
def splits_payload(self, *, days: int = 90) -> dict:
|
|
218
|
+
"""The claims-dashboard payload — every per-builder split for this seller,
|
|
219
|
+
ready to serialize to JSON. One reusable call behind a ``/splits`` route.
|
|
220
|
+
|
|
221
|
+
Each row carries the split address, its codes + share, live balance,
|
|
222
|
+
deployed state, and a permissionless ``[deploy?, distribute]`` claim.
|
|
223
|
+
Discovery is by our app code ``a`` (CDP); the claim is reconstructed from
|
|
224
|
+
the seller wallet THIS facade holds, so even undeployed splits build with
|
|
225
|
+
no guessing. The shared marker and any unregistered/unresolvable builder
|
|
226
|
+
code are skipped.
|
|
227
|
+
|
|
228
|
+
No per-split payment count: the query that would produce one 400s on
|
|
229
|
+
the CDP SQL API (queries.sql #5b). #5c has a cheap count-only alternative.
|
|
230
|
+
|
|
231
|
+
Needs ``cdp-sdk`` (discovery) + a Base RPC (balances). Returns a dict:
|
|
232
|
+
``{"configured": True, "marker": ..., "count": N, "splits": [...]}``.
|
|
233
|
+
"""
|
|
234
|
+
from . import monitor # lazy: only this path needs cdp_sql / cdp-sdk
|
|
235
|
+
|
|
236
|
+
codes = [
|
|
237
|
+
c for c in monitor.discover_builder_codes(self.app_code, days=days)
|
|
238
|
+
if c != AFFILIATION_MARKER
|
|
239
|
+
]
|
|
240
|
+
splits = []
|
|
241
|
+
for code in codes:
|
|
242
|
+
pt = self.resolve(code=code)
|
|
243
|
+
if not pt.attributed: # unregistered builder / no split for this pair
|
|
244
|
+
continue
|
|
245
|
+
# resolve() is memoized, so release() reuses it (one set of reads/code).
|
|
246
|
+
calls, balance = self.release(code)
|
|
247
|
+
splits.append({
|
|
248
|
+
"payTo": pt.address,
|
|
249
|
+
"sellerCode": self.app_code,
|
|
250
|
+
"builderCode": code,
|
|
251
|
+
"builderShareBps": self._share,
|
|
252
|
+
"balanceUnits": str(balance),
|
|
253
|
+
# deployed = no deploy leg needed (the split contract already exists).
|
|
254
|
+
"deployed": not any(c.step == "deploy_split" for c in calls),
|
|
255
|
+
"claimable": len(calls) > 0,
|
|
256
|
+
"calls": [{"step": c.step, "target": c.target, "data": c.data} for c in calls],
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
splits.sort(key=lambda s: int(s["balanceUnits"]), reverse=True)
|
|
260
|
+
return {
|
|
261
|
+
"configured": True,
|
|
262
|
+
"marker": AFFILIATION_MARKER,
|
|
263
|
+
"count": len(splits),
|
|
264
|
+
"splits": splits,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
# Offline-ish demo: no code → falls straight back to the seller (no network).
|
|
270
|
+
aff = Affiliation(app_code="bc_demo", seller_payout="0x2222222222222222222222222222222222222222")
|
|
271
|
+
print("extensions :", aff.extensions)
|
|
272
|
+
print("no code → payTo", aff.pay_to_for(None), "(seller, unsplit)")
|
|
273
|
+
print("header dict → payTo", aff.pay_to_for({Affiliation.HEADER: ""}), "(empty, unsplit)")
|
|
274
|
+
print("\nlive: aff.pay_to_for('leap_wallet') resolves the split; "
|
|
275
|
+
"aff.pending() lists what's owed; aff.release(code) builds the payout.")
|
x402aff/builder_code.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Base Builder Code (ERC-8021 Schema 2) helpers for x402 - declare & decode.
|
|
2
|
+
|
|
3
|
+
This is the one framework-agnostic, reusable piece of the whole affiliation
|
|
4
|
+
system. It does two jobs and has no web framework or database dependency:
|
|
5
|
+
|
|
6
|
+
1. declare_builder_code(app_code) - build the extension dict you attach to
|
|
7
|
+
your paid x402 route so every payment is stamped with YOUR app code ("a").
|
|
8
|
+
2. parse_builder_code_suffix(calldata) - decode the ERC-8021 suffix the
|
|
9
|
+
facilitator appended to a settlement transaction, so you can read back the
|
|
10
|
+
authoritative on-chain codes (most importantly "w", the facilitator wallet
|
|
11
|
+
code, which only exists after settlement).
|
|
12
|
+
|
|
13
|
+
Plus two normalizers used to sanitize codes read off the wire before they touch
|
|
14
|
+
a database.
|
|
15
|
+
|
|
16
|
+
Why hand-rolled? The x402 *Python* SDK ships no builder-code module (TS/Go
|
|
17
|
+
only), so the resource server has to build the declaration and decode the suffix
|
|
18
|
+
itself. On TypeScript you'd use `@x402/extensions/builder-code` instead.
|
|
19
|
+
|
|
20
|
+
Spec: https://github.com/x402-foundation/x402/blob/main/specs/extensions/builder_code.md
|
|
21
|
+
CDP: https://docs.cdp.coinbase.com/x402/core-concepts/builder-codes
|
|
22
|
+
|
|
23
|
+
The only third-party dependency is `cbor2`, and only for parse_builder_code_suffix
|
|
24
|
+
(the on-chain decode path). Declaring a code needs no dependencies at all.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
# Extension key, matching the x402 TS SDK (@x402/extensions/builder-code).
|
|
32
|
+
BUILDER_CODE_KEY = "builder-code"
|
|
33
|
+
|
|
34
|
+
# Every builder code (a / w / s) is 1-32 lowercase letters, digits, underscores.
|
|
35
|
+
BUILDER_CODE_PATTERN = r"^[a-z0-9_]{1,32}$"
|
|
36
|
+
|
|
37
|
+
# The SHARED marker code the kit's buyer extension appends as a SECOND `s` on
|
|
38
|
+
# every payment, so kit-routed payments self-identify on-chain. Because it is the
|
|
39
|
+
# same code across all kit installs, ONE query discovers every kit payment
|
|
40
|
+
# ecosystem-wide - `WHERE builder_code = <marker>` in base.transaction_attributions
|
|
41
|
+
# - with no split-address reconstruction, and it catches even UNDEPLOYED splits
|
|
42
|
+
# (the marker is written at settle time, not at deploy). It rides alongside the
|
|
43
|
+
# real builder code; the split always pays the PRIMARY (first) code, so the marker
|
|
44
|
+
# never changes a payout, and it needs no registration (it is stamped and indexed
|
|
45
|
+
# even when unregistered). It is HARDCODED and identical across every kit install -
|
|
46
|
+
# that shared constant is exactly what makes one query discover them all, so it is
|
|
47
|
+
# deliberately not configurable.
|
|
48
|
+
AFFILIATION_MARKER = "x402aff"
|
|
49
|
+
|
|
50
|
+
# ERC-8021 Schema 2 trailer (16 bytes) that closes every attribution suffix.
|
|
51
|
+
# Read from the END of the settlement calldata, the suffix is laid out as:
|
|
52
|
+
# [cbor_data][suffix_data_length (2 bytes, big-endian)][schema_id = 0x02][marker (16 bytes)]
|
|
53
|
+
ERC_8021_MARKER = bytes.fromhex("80218021802180218021802180218021")
|
|
54
|
+
_SCHEMA_2_ID = 2
|
|
55
|
+
|
|
56
|
+
# JSON Schema for the three ERC-8021 Schema 2 fields. Rides in the base64'd 402
|
|
57
|
+
# header, so it is kept minimal.
|
|
58
|
+
BUILDER_CODE_SCHEMA: dict[str, Any] = {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"properties": {
|
|
61
|
+
"a": {"type": "string", "pattern": BUILDER_CODE_PATTERN}, # app code (yours)
|
|
62
|
+
"w": {"type": "string", "pattern": BUILDER_CODE_PATTERN}, # facilitator wallet code
|
|
63
|
+
"s": { # service/referer code(s)
|
|
64
|
+
"type": "array",
|
|
65
|
+
"items": {"type": "string", "pattern": BUILDER_CODE_PATTERN},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
"additionalProperties": False,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def normalize_builder_code(code: Any) -> str | None:
|
|
73
|
+
"""Return ``code`` trimmed if it's a single valid builder code, else None.
|
|
74
|
+
|
|
75
|
+
Use it to sanitize a code read back off the wire (your declared ``a``, a
|
|
76
|
+
facilitator ``w``) before storing it. Never raises.
|
|
77
|
+
"""
|
|
78
|
+
if not isinstance(code, str):
|
|
79
|
+
return None
|
|
80
|
+
code = code.strip()
|
|
81
|
+
return code if re.match(BUILDER_CODE_PATTERN, code) else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def normalize_service_codes(raw: Any) -> str | None:
|
|
85
|
+
"""Normalize a submitted ``s`` field to a comma-joined string of codes.
|
|
86
|
+
|
|
87
|
+
The buyer's client extension sends ``s`` as a list (one entry per layered
|
|
88
|
+
client), but it can also arrive as a bare string. Returns the valid,
|
|
89
|
+
de-duplicated codes joined by ``,`` (order preserved), or None when empty -
|
|
90
|
+
ready to drop in a single TEXT/VARCHAR column. Invalid entries are dropped.
|
|
91
|
+
"""
|
|
92
|
+
if raw is None:
|
|
93
|
+
return None
|
|
94
|
+
items = raw if isinstance(raw, (list, tuple)) else [raw]
|
|
95
|
+
valid: list[str] = []
|
|
96
|
+
for item in items:
|
|
97
|
+
code = normalize_builder_code(item)
|
|
98
|
+
if code and code not in valid:
|
|
99
|
+
valid.append(code)
|
|
100
|
+
return ",".join(valid) or None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def marked_service_codes(code: str) -> list[str]:
|
|
104
|
+
"""The buyer-side ``s`` codes to attach: your real builder ``code``, plus the
|
|
105
|
+
shared kit marker as a second entry.
|
|
106
|
+
|
|
107
|
+
``buyer_client.BuilderCodeClientExtension`` applies this for you; reach for
|
|
108
|
+
this directly when you stamp ``s`` yourself - a different x402 client, the
|
|
109
|
+
official builder-code extension, or a hand-built extensions dict::
|
|
110
|
+
|
|
111
|
+
{"builder-code": {"info": {"s": marked_service_codes("bc_yourcode")}}}
|
|
112
|
+
|
|
113
|
+
The marker is dropped when it would duplicate ``code``. The split always pays
|
|
114
|
+
the PRIMARY (first) code, so this never changes a payout - it only makes the
|
|
115
|
+
payment discoverable (see queries.sql #5). Mirrors the TS
|
|
116
|
+
``markedServiceCodes``.
|
|
117
|
+
"""
|
|
118
|
+
return [code, AFFILIATION_MARKER] if AFFILIATION_MARKER != code else [code]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def declare_builder_code(app_code: str) -> dict[str, Any]:
|
|
122
|
+
"""Build the builder-code extension dict for your x402 route's `extensions`.
|
|
123
|
+
|
|
124
|
+
Returns ``{"builder-code": {"info": {"a": <app_code>}, "schema": ...}}``,
|
|
125
|
+
ready to merge into the route config you pass to the x402 middleware. This is
|
|
126
|
+
all it takes to declare your app code - the facilitator does the rest at
|
|
127
|
+
settlement. Raises ValueError if ``app_code`` is malformed.
|
|
128
|
+
"""
|
|
129
|
+
code = (app_code or "").strip()
|
|
130
|
+
if not re.match(BUILDER_CODE_PATTERN, code):
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"builder code {code!r} must match {BUILDER_CODE_PATTERN} "
|
|
133
|
+
"(1-32 lowercase letters, digits, underscores)"
|
|
134
|
+
)
|
|
135
|
+
return {BUILDER_CODE_KEY: {"info": {"a": code}, "schema": BUILDER_CODE_SCHEMA}}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_builder_code_suffix(calldata: Any) -> dict[str, Any] | None:
|
|
139
|
+
"""Decode the ERC-8021 Schema 2 builder-code suffix from settlement calldata.
|
|
140
|
+
|
|
141
|
+
The reverse of what the facilitator appends at settle time (the Python
|
|
142
|
+
equivalent of TS ``parseBuilderCodeSuffixFromCalldata``). Returns a dict with
|
|
143
|
+
whichever of ``a`` / ``w`` / ``s`` are present, or ``None`` when there is no
|
|
144
|
+
valid Schema 2 suffix - so it's safe to call on ANY transaction. Use it to
|
|
145
|
+
read the authoritative on-chain attribution off a settle tx - ``a`` / ``s``
|
|
146
|
+
and above all ``w`` (the facilitator wallet code), which only exists
|
|
147
|
+
post-settle.
|
|
148
|
+
|
|
149
|
+
``calldata`` may be a hex string (``0x...``) or raw ``bytes``. Never raises.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
if isinstance(calldata, str):
|
|
153
|
+
data = bytes.fromhex(calldata[2:] if calldata.startswith("0x") else calldata)
|
|
154
|
+
else:
|
|
155
|
+
data = bytes(calldata)
|
|
156
|
+
except (ValueError, TypeError):
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
# Need at least cbor(0+) + length(2) + schema(1) + marker(16); the marker and
|
|
160
|
+
# schema byte gate out any non-attributed transaction.
|
|
161
|
+
if len(data) < 19 or data[-16:] != ERC_8021_MARKER or data[-17] != _SCHEMA_2_ID:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
cbor_len = int.from_bytes(data[-19:-17], "big")
|
|
165
|
+
start = len(data) - 19 - cbor_len
|
|
166
|
+
if start < 0:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
# cbor2 is only needed on this decode path - import it lazily so declaring a
|
|
170
|
+
# code stays dependency-free.
|
|
171
|
+
try:
|
|
172
|
+
import cbor2
|
|
173
|
+
|
|
174
|
+
decoded = cbor2.loads(data[start : len(data) - 19])
|
|
175
|
+
except Exception:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
if not isinstance(decoded, dict):
|
|
179
|
+
return None
|
|
180
|
+
out: dict[str, Any] = {}
|
|
181
|
+
for key in ("a", "w", "s"):
|
|
182
|
+
val = decoded.get(key)
|
|
183
|
+
if val not in (None, "", []):
|
|
184
|
+
out[key] = val
|
|
185
|
+
return out or None
|
x402aff/buyer_client.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Buyer side: how a builder attaches their code to EARN on the runs they drive.
|
|
2
|
+
|
|
3
|
+
This is the ONLY thing a builder (an app/agent that pays you on behalf of its
|
|
4
|
+
users) has to do to get attributed and paid. They register a tiny client
|
|
5
|
+
extension on the x402 client they pay with, and every payment then carries their
|
|
6
|
+
code as ``s`` (the referer). No contract changes; the facilitator writes it
|
|
7
|
+
on-chain at settlement.
|
|
8
|
+
|
|
9
|
+
Get a code (format ^[a-z0-9_]{1,32}$) at base.dev → Settings → Builder Codes,
|
|
10
|
+
then register the payout wallet you want the share sent to.
|
|
11
|
+
|
|
12
|
+
--------------------------------------------------------------------------------
|
|
13
|
+
Python - the x402 Python SDK has no builder-code module, but its client takes
|
|
14
|
+
generic extensions and the resource server declares the "builder-code" key in
|
|
15
|
+
its 402, so this matching extension fires and tags the code as `s`:
|
|
16
|
+
|
|
17
|
+
Every payment also carries a fixed, SHARED kit marker as a second `s` (see
|
|
18
|
+
`builder_code.AFFILIATION_MARKER`) so kit-routed payments self-identify on-chain:
|
|
19
|
+
discovery across all sellers is then one query, `WHERE builder_code = <marker>`,
|
|
20
|
+
with no reconstruction and undeployed splits included. The marker never affects a
|
|
21
|
+
payout (the split pays the primary code, i.e. yours) and is hardcoded and shared
|
|
22
|
+
across every install - not configurable, so kit payments always stay discoverable.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from .builder_code import marked_service_codes
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BuilderCodeClientExtension:
|
|
30
|
+
"""Stamp every payment from this client with your builder code as ``s``.
|
|
31
|
+
|
|
32
|
+
A hardcoded, shared kit marker (:data:`builder_code.AFFILIATION_MARKER`,
|
|
33
|
+
``x402aff``) rides along as a SECOND ``s`` code so kit-routed payments are
|
|
34
|
+
discoverable on-chain with one query (``WHERE builder_code = 'x402aff'``). It
|
|
35
|
+
never changes a payout - the split always pays the PRIMARY (first) code, i.e.
|
|
36
|
+
*your* ``code`` - and is deliberately not configurable, so every kit payment
|
|
37
|
+
stays discoverable ecosystem-wide.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
key = "builder-code"
|
|
41
|
+
|
|
42
|
+
def __init__(self, code: str):
|
|
43
|
+
self.code = code
|
|
44
|
+
|
|
45
|
+
def enrich_payment_payload(self, payload, payment_required):
|
|
46
|
+
# Always append the shared, hardcoded marker as a second `s` (never a
|
|
47
|
+
# duplicate of the real code), so the payment self-identifies on-chain.
|
|
48
|
+
codes = marked_service_codes(self.code)
|
|
49
|
+
exts = dict(payload.extensions or {})
|
|
50
|
+
exts["builder-code"] = {"info": {"s": codes}}
|
|
51
|
+
return payload.model_copy(update={"extensions": exts})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Usage - on the same x402ClientSync() you registered the payment scheme on:
|
|
55
|
+
#
|
|
56
|
+
# client.register_extension(BuilderCodeClientExtension("bc_yourcode"))
|
|
57
|
+
# client.fetch("https://api.example.com/run", ...) # now carries your code
|
|
58
|
+
#
|
|
59
|
+
# --------------------------------------------------------------------------------
|
|
60
|
+
# TypeScript - use the official extension instead (npm install @x402/extensions):
|
|
61
|
+
#
|
|
62
|
+
# import { x402Client } from "@x402/fetch";
|
|
63
|
+
# import { registerExactEvmScheme } from "@x402/evm/exact/client";
|
|
64
|
+
# import { BuilderCodeClientExtension } from "@x402/extensions/builder-code";
|
|
65
|
+
# import { privateKeyToAccount } from "viem/accounts";
|
|
66
|
+
#
|
|
67
|
+
# const client = new x402Client();
|
|
68
|
+
# registerExactEvmScheme(client, {
|
|
69
|
+
# signer: privateKeyToAccount(process.env.X402_BUYER_PRIVATE_KEY),
|
|
70
|
+
# });
|
|
71
|
+
# client.registerExtension(new BuilderCodeClientExtension("bc_yourcode"));
|
|
72
|
+
# // every client.fetch(...) payment now carries your code as `s`
|
|
73
|
+
#
|
|
74
|
+
# --------------------------------------------------------------------------------
|
|
75
|
+
# Verify (after a real Base-mainnet settlement): take the settle tx hash (from the
|
|
76
|
+
# PAYMENT-RESPONSE header) and paste it into buildercode-checker.vercel.app - you
|
|
77
|
+
# should see the resource server's `a`, the CDP facilitator's `w`, and your `s`.
|
x402aff/cdp_sql.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Thin client for the CDP SQL API - query Coinbase's decoded on-chain tables.
|
|
2
|
+
|
|
3
|
+
This is the alternative to hitting a raw Base RPC and hand-parsing calldata: CDP
|
|
4
|
+
already decodes builder-code attribution into queryable tables, so you can just
|
|
5
|
+
ask SQL for it. See monitor.py for the affiliation use, and queries.sql for
|
|
6
|
+
copy-paste queries you can run right now in the no-auth SQL Playground.
|
|
7
|
+
|
|
8
|
+
Endpoint : POST https://api.cdp.coinbase.com/platform/v2/data/query/run
|
|
9
|
+
Auth : Authorization: Bearer <JWT> (CDP API-key JWT, ~2 min expiry)
|
|
10
|
+
Body : {"sql": "...", "cache": {"maxAgeMs": <int>}}
|
|
11
|
+
Response : {"result": [ {col: val, ...}, ... ], "schema": {...}}
|
|
12
|
+
Dialect : ClickHouse (CoinbaSeQL). Read-only SELECT. Max 10k-char query,
|
|
13
|
+
50k-row result, 30s timeout. https://docs.cdp.coinbase.com/data/sql-api
|
|
14
|
+
|
|
15
|
+
No API key needed to explore: the SQL Playground at
|
|
16
|
+
https://portal.cdp.coinbase.com/onchain-tools/sql-api runs the same queries in
|
|
17
|
+
the browser. Only the programmatic path below needs a JWT.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
SQL_API_URL = "https://api.cdp.coinbase.com/platform/v2/data/query/run"
|
|
26
|
+
_HOST = "api.cdp.coinbase.com"
|
|
27
|
+
_PATH = "/platform/v2/data/query/run"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _bearer_token() -> str:
|
|
31
|
+
"""Get a Bearer JWT for the SQL API.
|
|
32
|
+
|
|
33
|
+
Two ways, in order:
|
|
34
|
+
1. CDP_API_KEY_ID + CDP_API_KEY_SECRET set → mint a fresh JWT with the CDP
|
|
35
|
+
SDK (`pip install cdp-sdk`). This is the production path; JWTs expire in
|
|
36
|
+
~2 min, so mint one per call.
|
|
37
|
+
2. CDP_JWT set → use it verbatim (handy for a quick test: generate one in
|
|
38
|
+
the portal or via the SDK and export it).
|
|
39
|
+
|
|
40
|
+
Get keys at https://portal.cdp.coinbase.com/api-keys.
|
|
41
|
+
"""
|
|
42
|
+
key_id = os.environ.get("CDP_API_KEY_ID")
|
|
43
|
+
key_secret = os.environ.get("CDP_API_KEY_SECRET")
|
|
44
|
+
if key_id and key_secret:
|
|
45
|
+
# Exact helper from the CDP docs (docs.cdp.coinbase.com JWT authentication).
|
|
46
|
+
from cdp.auth.utils.jwt import JwtOptions, generate_jwt
|
|
47
|
+
|
|
48
|
+
return generate_jwt(JwtOptions(
|
|
49
|
+
api_key_id=key_id,
|
|
50
|
+
api_key_secret=key_secret,
|
|
51
|
+
request_method="POST",
|
|
52
|
+
request_host=_HOST,
|
|
53
|
+
request_path=_PATH,
|
|
54
|
+
expires_in=120,
|
|
55
|
+
))
|
|
56
|
+
token = os.environ.get("CDP_JWT")
|
|
57
|
+
if token:
|
|
58
|
+
return token
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
"No CDP credentials. Set CDP_API_KEY_ID + CDP_API_KEY_SECRET (needs "
|
|
61
|
+
"`pip install cdp-sdk`), or set CDP_JWT to a pre-generated token. "
|
|
62
|
+
"Or just paste the SQL into the no-auth Playground (see queries.sql)."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run_query(sql: str, *, max_age_ms: int = 5000, timeout: float = 35.0) -> list[dict]:
|
|
67
|
+
"""Run a read-only SQL query and return the rows (the ``result`` array).
|
|
68
|
+
|
|
69
|
+
``max_age_ms`` lets the API serve a cached result when an identical query ran
|
|
70
|
+
within that window (up to 900_000ms / 15m) - cheaper for repeated scans (monitor.py).
|
|
71
|
+
"""
|
|
72
|
+
resp = requests.post(
|
|
73
|
+
SQL_API_URL,
|
|
74
|
+
headers={
|
|
75
|
+
"Authorization": f"Bearer {_bearer_token()}",
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
},
|
|
78
|
+
json={"sql": sql, "cache": {"maxAgeMs": max_age_ms}},
|
|
79
|
+
timeout=timeout,
|
|
80
|
+
)
|
|
81
|
+
# Surface the API's error BODY, not just the status - a 400 here is almost
|
|
82
|
+
# always a SQL error the body explains (raise_for_status drops it).
|
|
83
|
+
if not resp.ok:
|
|
84
|
+
raise RuntimeError(f"CDP SQL {resp.status_code}: {(resp.text or '')[:600]}")
|
|
85
|
+
return resp.json().get("result", [])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def sql_in_list(values) -> str:
|
|
89
|
+
"""Render a Python iterable of strings as a ClickHouse IN (...) list.
|
|
90
|
+
|
|
91
|
+
Values here are tx hashes (0x + 64 hex) or builder codes ([a-z0-9_]) - both
|
|
92
|
+
already constrained to safe characters - but we still hard-filter to those
|
|
93
|
+
charsets so nothing but a hash/code can reach the query string.
|
|
94
|
+
"""
|
|
95
|
+
import re
|
|
96
|
+
|
|
97
|
+
safe = [v for v in values if isinstance(v, str) and re.match(r"^[0-9a-zA-Z_x]+$", v)]
|
|
98
|
+
return ", ".join("'" + v + "'" for v in safe)
|