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 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.")
@@ -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
@@ -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)