Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ Three view-calls against two contracts - port
- **Use a paid RPC.** Set `X402_BASE_RPC` - the public one `429`s, and a failed
resolve silently falls back to your wallet (unsplit).
- **Tell builders how their code resolved.** Echo `PayTo.status` (`resolved`,
`unregistered`, `invalid`, `error`, `none`) in the `X-Builder-Code-Status`
`unregistered`, `blocked`, `invalid`, `error`, `none`) in the `X-Builder-Code-Status`
response header (`STATUS_HEADER`). Without it, an unminted or mistyped code
looks exactly like a working one. A code shown on base.dev only resolves once
it is minted on the registry (`isRegistered(string)` returns `true`).
Expand Down Expand Up @@ -259,6 +259,9 @@ on-chain attack surface, and the money is held by code that is already audited.
Every split is created **ownerless** (`owner = 0`) and immutable.
- **Base Builder Codes registry** (`0x000000BC…C8E80`) maps a code to the payout its
owner registered. Read-only, so a builder's cut can only go where that builder said.
The kit screens that payout first: a split to the USDC contract, or to an address
USDC has blacklisted, could never pay out, so such a code pays you in full instead
(status `blocked`).

### What the split guarantees - proven against live mainnet

Expand Down
7 changes: 6 additions & 1 deletion docs/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ facade wraps the modules below.
402 for each needs no live RPC call - the public RPC rate-limits.
5. **Echo the status** (recommended). Set the `X-Builder-Code-Status` response
header (`payto.STATUS_HEADER`) from `PayTo.status`: `resolved`,
`unregistered`, `invalid`, `error`, or `none`. Every fallback otherwise looks
`unregistered`, `blocked`, `invalid`, `error`, or `none`. Every fallback otherwise looks
like a normal 402, so a builder with an unminted or mistyped code would earn
0% without knowing. With the header it can assert `resolved` at startup.

Expand Down Expand Up @@ -196,6 +196,11 @@ exactly the right shape.

- **No / unregistered / blank code** → `payTo` = your wallet, 100% to you, never
stranded. `PayTo.status` says which (`none` / `unregistered` / `invalid`).
- **A payout USDC refuses** (the USDC contract itself, or an address USDC has
blacklisted) → `payTo` = your wallet, status `blocked`. A split always pays every
recipient at once, so one such recipient would leave the whole payment stuck in
it. Checked when the code is resolved; a payout blacklisted after that is only
picked up when the process restarts or the cache is cleared.
- **A code shown on base.dev is not always minted on-chain.** Only a minted code
resolves. Check with `cast call 0x000000BC7E6457e610fe52Dcc0ca5b3ce59C8E80
'isRegistered(string)(bool)' bc_yourcode`; `false` means it earns nothing until
Expand Down
2 changes: 1 addition & 1 deletion python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ calls, balance = aff.release("bc_alice")
```

`aff.resolve(request)` returns the full `PayTo`, including `status`
(`resolved` / `unregistered` / `invalid` / `error` / `none`). Echo it in the
(`resolved` / `unregistered` / `blocked` / `invalid` / `error` / `none`). Echo it in the
`aff.STATUS_HEADER` (`X-Builder-Code-Status`) response header so a builder can
tell a working code from an unminted or mistyped one.

Expand Down
29 changes: 29 additions & 0 deletions python/tests/test_affiliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,35 @@ def test_pay_to_async_callback_registered(monkeypatch):
assert addr == SPLIT


def test_pay_to_does_not_block_the_event_loop(monkeypatch):
# The resolve does blocking RPC reads; run on the loop, a slow RPC stalled
# every other request the server was handling.
import time

from x402aff import payto, split

plan = split.build_split_plan(SELLER, BUILDER, builder_code="bc_alice")

def _slow(code, **kw):
time.sleep(0.3)
return payto.PayTo(SPLIT, plan, False, True, status=payto.STATUS_RESOLVED)

monkeypatch.setattr(payto, "payto_for_request", _slow)
aff = _aff()

async def main():
task = asyncio.create_task(aff.pay_to(_FakeCtx("bc_alice")))
ticks = 0
while not task.done():
await asyncio.sleep(0.01)
ticks += 1
return await task, ticks

addr, ticks = asyncio.run(main())
assert addr == SPLIT
assert ticks >= 5, "the loop kept serving while the resolve ran"


def test_balance_reads_split(monkeypatch):
_patch_registered(monkeypatch, balance=12_400_000)
assert _aff().balance("bc_alice") == 12_400_000
Expand Down
101 changes: 101 additions & 0 deletions python/tests/test_payto.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,107 @@ def test_unregistered_negative_cache_expires_and_picks_up_registration(monkeypat
assert second.attributed and second.address == SPLIT


# ── payout screen, end to end through a fake Base RPC ─────────────────────────
#
# A PushSplit is ownerless and pays every recipient in one distribute, so a
# builder payout USDC refuses (a blacklisted address, or USDC's own address,
# which Base USDC reports as blacklisted) makes every distribute revert and
# strands the whole payment, the seller's share too. These drive the real
# resolver -> split -> payto path against a fake JSON-RPC (registry, USDC and
# factory) and check such a payout never becomes the advertised split.

USDC = push_split.USDC_BASE
BLACKLISTED = "0x0000000000000000000000000000000000000bad"
GOOD = "0x00000000000000000000000000000000000600d0"
OWNER = "0x00000000000000000000000000000000000a77ac"


def _fake_rpc(monkeypatch, payout, *, blacklisted=(BLACKLISTED, USDC), usdc_error=None):
"""Answer eth_calls like Base would for a code registered to `payout`.
Returns the list of (to, selector) calls seen."""
seen = []
listed = {a.lower() for a in blacklisted}

class _Resp:
def __init__(self, body):
self._body = body

def raise_for_status(self):
pass

def json(self):
return self._body

def _word(addr):
return "0x" + addr[2:].lower().rjust(64, "0")

def _post(url, json, timeout):
call = json["params"][0]
to, data = call["to"].lower(), call["data"]
seen.append((to, data[:10]))
if to == payto.resolver.BUILDER_CODES_REGISTRY.lower():
return _Resp({"result": _word(OWNER if data[2:10] == "6352211e" else payout)})
if to == USDC.lower():
if usdc_error:
return _Resp({"error": usdc_error})
arg = "0x" + data[-40:]
return _Resp({"result": "0x" + ("1" if arg in listed else "0").rjust(64, "0")})
if to == push_split.SPLITS_PUSH_FACTORY.lower():
return _Resp({"result": _word(SPLIT) + "0" * 64})
raise AssertionError(f"unexpected eth_call to {to}")

monkeypatch.setattr(payto.resolver.requests, "post", _post)
return seen


def test_good_payout_still_routes_to_the_split(monkeypatch):
seen = _fake_rpc(monkeypatch, GOOD)
r = payto.payto_for_request("bc_alice", seller_payout=SELLER)
assert r.status == payto.STATUS_RESOLVED and r.attributed
assert r.address.lower() == SPLIT
assert dict(r.plan.recipients) == {GOOD: 1000, SELLER: 9000}
# the payout was screened against USDC before the split was predicted
assert (USDC.lower(), "0xfe575a87") in seen


def test_blacklisted_payout_pays_the_seller_in_full(monkeypatch):
seen = _fake_rpc(monkeypatch, BLACKLISTED)
r = payto.payto_for_request("bc_alice", seller_payout=SELLER)
assert r.status == payto.STATUS_BLOCKED
assert r.address == SELLER and not r.attributed and r.error is None
assert r.plan.recipients == [(SELLER, split.BPS_DENOM)] and r.plan.payout_blocked
# no split address is ever predicted for it
assert not any(to == push_split.SPLITS_PUSH_FACTORY.lower() for to, _ in seen)


def test_usdc_contract_as_payout_pays_the_seller_in_full(monkeypatch):
# Blocked by the static check alone, so even a node that answered
# isBlacklisted(USDC) wrongly could not let it through.
seen = _fake_rpc(monkeypatch, USDC, blacklisted=())
r = payto.payto_for_request("bc_alice", seller_payout=SELLER)
assert r.status == payto.STATUS_BLOCKED
assert r.address == SELLER and not r.attributed
assert not any(to == USDC.lower() for to, _ in seen), "no RPC needed for USDC itself"


def test_blocked_code_is_served_from_the_negative_cache(monkeypatch):
seen = _fake_rpc(monkeypatch, BLACKLISTED)
payto.payto_for_request("bc_alice", seller_payout=SELLER)
n = len(seen)
again = payto.payto_for_request("bc_alice", seller_payout=SELLER)
assert again.status == payto.STATUS_BLOCKED and again.address == SELLER
assert again.plan.payout_blocked
assert len(seen) == n, "a repeated blocked code must not re-hit the RPC"


def test_blacklist_read_failure_pays_the_seller_and_reports_it(monkeypatch):
_fake_rpc(monkeypatch, GOOD, usdc_error={"code": -32005, "message": "limit exceeded"})
r = payto.payto_for_request("bc_alice", seller_payout=SELLER)
assert r.status == payto.STATUS_ERROR
assert r.address == SELLER and not r.attributed
assert r.error and "USDC isBlacklisted" in r.error


# ── distribute leg selection ──────────────────────────────────────────────────

def test_distribute_calls_include_deploy_when_undeployed():
Expand Down
45 changes: 45 additions & 0 deletions python/tests/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,51 @@ def test_registered_code_returns_owner_and_payout(monkeypatch):
assert info["code"] == CODE


def test_resolve_flags_a_blacklisted_payout(monkeypatch):
owner_word = _word("0x" + "a" * 40)
true_word = "0x" + "0" * 63 + "1"
monkeypatch.setattr(resolver.requests, "post",
_post_returning({"result": owner_word}, {"result": _word(PAYOUT)},
{"result": true_word}))
info = resolver.resolve(CODE)
assert info["registered"] is True and info["payout_address"] == PAYOUT
assert info["payout_blocked"] is True


# ── payout screen ─────────────────────────────────────────────────────────────

def test_payout_blocked_asks_usdc_isblacklisted(monkeypatch):
seen = {}

def _post(url, json, timeout):
seen.update(json["params"][0])
return _Resp({"result": "0x" + "0" * 64})

monkeypatch.setattr(resolver.requests, "post", _post)
assert resolver.payout_blocked(PAYOUT) is False
assert seen["to"] == resolver.USDC_BASE
assert seen["data"] == "0xfe575a87" + "0" * 24 + PAYOUT[2:]


def test_usdc_and_zero_are_blocked_without_rpc(monkeypatch):
monkeypatch.setattr(resolver.requests, "post", lambda *a, **k: pytest.fail("no RPC expected"))
assert resolver.payout_blocked(resolver.USDC_BASE) is True
assert resolver.payout_blocked(resolver.USDC_BASE.lower()) is True
assert resolver.payout_blocked("0x" + "0" * 40) is True


def test_payout_screen_failure_raises(monkeypatch):
# Neither "blocked" nor "fine" is safe to guess: raise, and payto pays the
# seller unsplit with the error recorded.
monkeypatch.setattr(resolver.requests, "post",
_post_returning({"error": {"code": -32005, "message": "limit exceeded"}}))
with pytest.raises(RuntimeError, match="USDC isBlacklisted eth_call failed"):
resolver.payout_blocked(PAYOUT)
monkeypatch.setattr(resolver.requests, "post", _post_returning({"result": "0x"}))
with pytest.raises(RuntimeError, match="no data"):
resolver.payout_blocked(PAYOUT)


def test_zero_owner_word_means_unregistered(monkeypatch):
monkeypatch.setattr(resolver.requests, "post", _post_returning({"result": _word("0x" + "0" * 40)}))
assert resolver.resolve(CODE)["registered"] is False
Expand Down
16 changes: 16 additions & 0 deletions python/tests/test_split.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,22 @@ def test_resolve_and_plan_unregistered_falls_back_to_seller(monkeypatch):
plan = split.resolve_and_plan("bc_ghost", SELLER)
assert not plan.has_builder
assert plan.recipients == [(SELLER, split.BPS_DENOM)]
assert not plan.payout_blocked


def test_resolve_and_plan_drops_a_blocked_payout(monkeypatch):
# Registered, but USDC refuses the payout: a split to it could never
# distribute, so the seller is paid in full instead.
monkeypatch.setattr(
split.resolver,
"resolve",
lambda code, **kw: {"registered": True, "payout_address": BUILDER,
"payout_blocked": True},
)
plan = split.resolve_and_plan("bc_alice", SELLER)
assert not plan.has_builder
assert plan.recipients == [(SELLER, split.BPS_DENOM)]
assert plan.payout_blocked and plan.builder_code == "bc_alice"


def test_resolve_and_plan_no_code_is_seller_only(monkeypatch):
Expand Down
12 changes: 11 additions & 1 deletion python/x402aff/affiliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"""
from __future__ import annotations

import asyncio
import dataclasses
import logging
from typing import TYPE_CHECKING, Any, Optional
Expand Down Expand Up @@ -122,8 +123,17 @@ async def pay_to(self, ctx) -> str:
Pass as ``PaymentOption(pay_to=aff.pay_to)``. Reads ``X-Builder-Code`` off
the request context and returns the per-pair split address, or the seller
wallet when there's no/unknown/unresolvable code.

The resolve does blocking RPC reads (``requests``), so it runs in a worker
thread: on the event loop a slow or rate-limited RPC would stall every
other request the server is handling. The header is read here first, on
the loop, so the request object never crosses threads.
"""
return self.resolve(ctx).address
code = self._code_from(ctx)
if not code:
return self.resolve(ctx).address # no code: no RPC, nothing to offload
pt = await asyncio.to_thread(self.resolve, code=code)
return pt.address

def pay_to_for(self, source=None, *, code: Optional[str] = None) -> str:
"""Sync ``payTo`` for a request - for stacks where you set it yourself.
Expand Down
Loading