Metadata-Version: 2.4
Name: pxke-x402-client
Version: 0.8.1
Summary: Thin client for PXke's x402 marketplace (https://algorand-api.pxke.me): free news/catalog reads, wallet-signed free storage reads, and x402-paid scan, news search and storage writes on Algorand mainnet.
Author: PXke
License: MIT
Project-URL: Homepage, https://algorand-api.pxke.me
Project-URL: Documentation, https://algorand-api.pxke.me/api/v1/x402
Keywords: x402,algorand,payments,agents,usdc
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.31.0
Requires-Dist: py-algorand-sdk>=2.5.0
Requires-Dist: msgpack>=1.0.0
Requires-Dist: x402-avm[avm]>=2.0.2
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Dynamic: license-file

# pxke-x402-client

A thin Python client for [PXke's x402 marketplace](https://algorand-api.pxke.me)
(`GET /api/v1/x402` is the live catalog). Free reads need nothing; paid calls
sign and settle a real [x402](https://x402.org) payment on **Algorand
mainnet** through the GoPlausible facilitator, then return the parsed JSON
response.

This is deliberately small: one class, one file of client logic, no retries
beyond the one 402->pay->retry round-trip the protocol itself requires, no
caching, no CLI. It never pays anything on its own: every payment is one you
asked for by calling a paid method. It wraps the marketplace's three products
plus its catalog:

| Product | Method | Route | Paid? |
| --- | --- | --- | --- |
| Catalog | `catalog()` | `GET /api/v1/x402` | free |
| Catalog | `settlements()` | `GET /api/v1/x402/settlements` | free |
| Catalog | `settlements_recent(limit=None)` | `GET /api/v1/x402/settlements/recent` | free |
| News Engine | `news(tag=None, limit=None, lang=None)` | `GET /api/v1/x402/news` | free |
| News Engine | `news_tags(limit=None)` | `GET /api/v1/x402/news/tags` | free |
| News Engine | `read_article(article_id)` | `GET /api/v1/x402/news/articles/:id` | free |
| News Engine | `search_news(q, limit=None, ...)` | `GET /api/v1/x402/news/search` | paid |
| URL scan | `scan_url(url, ...)` | `POST /api/v1/x402/scan/url` | paid |
| Storage | `storage_create_backup(data, label="", retention_days=None, ...)` | `POST /api/v1/x402/storage/backups` | paid |
| Storage | `storage_renew_backup(backup_id, wallet, ...)` | `POST /api/v1/x402/storage/backups/:id/renew` | paid |
| Storage | `storage_add_version(backup_id, data, wallet=None, label="", retention_days=None, ...)` | `POST /api/v1/x402/storage/backups/:id/versions` | paid |
| Storage | `storage_list_backups(limit=None)` | `GET /api/v1/x402/storage/backups` | free, signed |
| Storage | `storage_get_backup(backup_id)` | `GET /api/v1/x402/storage/backups/:id` | free, signed |
| Storage | `storage_delete_backup(backup_id)` | `DELETE /api/v1/x402/storage/backups/:id` | free, signed |
| Storage | `storage_list_versions(backup_id, limit=None)` | `GET /api/v1/x402/storage/backups/:id/versions` | free, signed |
| Storage | `storage_get_version(backup_id, version)` | `GET /api/v1/x402/storage/backups/:id/versions/:version` | free, signed |

"Free, signed" routes cost nothing but prove wallet ownership with a fresh
signature per call (see **Storage reads: wallet-signature auth**); they need a
client built with `mnemonic=` or `signer=`.

`GET /api/v1/x402` is the authoritative, current list of routes and prices --
always trust it over this README.

## This talks to Algorand MAINNET with real money

Paid calls in this package spend real USDC (or EURQ/USDQ, when the
marketplace happens to prefer them) from the wallet whose mnemonic you pass
in. There is no testnet mode and no dry-run flag. Before using a paid method:

- Fund the wallet with a small amount of ALGO (fees) and the asset you intend
  to pay with.
- **Opt the wallet into that asset** (e.g. USDC, ASA id `31566704`) before
  calling any paid method -- an un-opted-in wallet cannot be the sender of an
  asset transfer, and the payment will fail to build.
- Treat the mnemonic like any other private key: never commit it, never log
  it, never pass it to anything you don't control.
- Start with `preview=True` (see **Preview and promo codes**) -- a free,
  unpaid call that returns the same JSON shape with every value redacted,
  so you can check your integration end-to-end before spending anything.
  Then make your first real call against the cheapest route
  (`search_news`, $0.001 at last check) before an actual scan or backup.

Every paid call is also protected client-side before anything is signed: the
402 offer's recipient must match PXke's known `payTo` address
(`expected_pay_to=`) and its amount must stay under `max_payment_atomic=`
($1.00-equivalent by default) -- see `PxkeClient`'s docstring.

## Install

Not on PyPI yet, but self-hosted -- install straight from our own server, no
PyPI account needed on either side:

```bash
pip install https://algorand-api.pxke.me/sdk/latest.tar.gz
```

Or skip pip entirely and just grab the single-file version:

```bash
curl -O https://algorand-api.pxke.me/sdk/pxke_x402.py
# then: import pxke_x402  (needs requests, py-algorand-sdk, msgpack, x402-avm)
```

`https://algorand-api.pxke.me/sdk/latest.py` always points at the current
single-file build.

From a local checkout (contributing, or before this is hosted):

```bash
pip install -e .
```

Once published to PyPI itself (see **Publishing**, below):

```bash
pip install pxke-x402-client
```

Requires Python >= 3.10.

## Quickstart: free-only agent

No wallet needed for any of these:

```python
from pxke_x402 import PxkeClient

client = PxkeClient()

catalog = client.catalog()               # GET /api/v1/x402 -- routes, prices, assets
for route in catalog["routes"]:
    print(route["method"], route["path"], route["paid"], route["price_display"])

tags = client.news_tags(limit=20)        # GET /api/v1/x402/news/tags -- valid tag= values
headlines = client.news(tag="defi", limit=10)   # GET /api/v1/x402/news
for item in headlines["items"]:
    print(item["published_at_epoch"], item["title"])

# The full article is free too -- the same content as the public website.
article = client.read_article(headlines["items"][0]["slug"])  # GET /api/v1/x402/news/articles/:id
print(article["title"])

french = client.news(lang="fr", limit=5)   # stored translations where they exist, English otherwise

feed = client.settlements()   # GET /api/v1/x402/settlements -- newest real settlements, bounded
```

## Quickstart: paying agent

```python
from pxke_x402 import PxkeClient, PxkePaymentError

client = PxkeClient(mnemonic="word1 word2 ... word25")
print(client.address)  # the payer wallet this client signs for

# Sandboxed URL scan ($0.01 at last check): the file is fetched server-side,
# never executed, and checked with ClamAV + file-type + entropy + embedded
# URL/IP extraction (+ a bomb-safe member listing for zip/tar).
report = client.scan_url("https://example.com/suspicious-download.zip")
print(report["risk"]["verdict"], report["risk"]["malicious"], report["risk"]["score"])
print(report["settlement_tx_id"])

# Agent backup storage: store an opaque blob, priced per KB from len(data).
# Encrypt it yourself first if it is sensitive -- the server stores it as-is.
backup = client.storage_create_backup(b"...your agent state...", label="nightly")
backup_id = backup["backup_id"]

# Later: extend its retention window (only the wallet that created it may).
client.storage_renew_backup(backup_id, wallet=client.address)

# Add a new version under the same id (paid, priced like the first store);
# older versions stay retrievable until their own expiry.
client.storage_add_version(backup_id, b"...newer state...", label="nightly-2")

# Ranked full-text search over the news archive ($0.001 at last check; the
# article read itself is free -- see above).
results = client.search_news("tinyman volume", limit=5)
```

## Storage reads: wallet-signature auth

Listing, reading and deleting backups and their versions are free -- nothing
is paid on these calls -- but only the wallet that created a backup can do
them, proven fresh on every call (there is no session):

```python
client = PxkeClient(mnemonic="word1 word2 ... word25")

for item in client.storage_list_backups()["items"]:
    print(item["backup_id"], item["current_version"], item["expires_at_epoch"])

backup = client.storage_get_backup(backup_id)        # current version
restored: bytes = backup["data"]                     # decoded from base64 for you

for v in client.storage_list_versions(backup_id)["items"]:
    print(v["version"], v["size_bytes"], v["created_at_epoch"])
old = client.storage_get_version(backup_id, 1)["data"]

client.storage_delete_backup(backup_id)              # every version, irreversible
```

Each call makes two requests: `POST /api/v1/x402/storage/auth/challenge`
(`{"wallet": ...}`) returns a single-use `nonce` and a `signing_message`
(valid 300 seconds); the client checks that message names its own wallet and
that nonce, signs it, and sends `wallet`, `nonce`, `proof_method=signed_bytes`
and `signature_b64` as query params on the real call. The signature is the
algosdk `signBytes` convention -- Ed25519 over `b"MX" + signing_message` --
the same thing Pera's `signData` produces; the `MX` prefix means it can never
be replayed as a transaction signature. The server consumes the nonce on the
first attempt, so a refused call is never retried with it. Both requests
count against the server's hourly per-IP budget for these routes (and the
call itself against a per-wallet budget once verified).

The restored `data` is re-hashed and checked against the `content_hash` the
server reports (`PxkeIntegrityError` on a mismatch). A refused signature
raises `PxkeAuthError` (a `PxkeHTTPError` with `status_code` 401); a backup
that does not exist, is not yours, was deleted or has expired is a plain 404
`PxkeHTTPError`.

### Bringing your own signer

If the key is not a mnemonic you can hand to the client (a KMS, a hardware
wallet bridge, a wallet SDK), pass any object with:

- `address` -- the 58-character Algorand address;
- `sign_bytes(message: bytes) -> bytes` -- the raw 64-byte Ed25519 signature
  over `b"MX" + message` (prepend `MX` yourself if your API signs raw bytes);
- optionally `sign_transactions(unsigned_txns, indexes_to_sign)` (same
  contract as `pxke_x402.WorkingAvmSigner`), needed only for paid calls.

```python
client = PxkeClient(signer=my_signer)   # MessageSigner protocol in pxke_x402.signer
```

## Preview and promo codes

Every paid method also accepts `preview: bool = False` and
`promo_code: str | None = None, promo_wallet: str | None = None` -- the
client-side plumbing for the marketplace's two payment-gate bypasses.
`search_news` and `scan_url` honor `preview` as of this writing; always
check a route's `supports_preview`/`supports_promo` in `client.catalog()`
before relying on either -- that live catalog is the source of truth, not
this README. See `docs/x402-marketplace-api.md`'s "Preview and promo codes"
section in the PXke Algorand backend repo for the full server-side contract.

```python
# Preview: no payment, a redacted response with the same JSON shape. For the
# scan this is a FIXED fake report -- no target is ever fetched or scanned,
# and risk.malicious is None, never a verdict.
# Still needs a client built with a mnemonic (or an injected http_client) --
# the client can't yet tell in advance that no payment will be required.
report = client.scan_url("https://example.com/file.zip", preview=True)
print(report["settlement_tx_id"])  # "<preview>"

# Promo code: an admin-issued code redeems for a bounded number of free
# uses. There is no public way to create one -- codes are issued only
# through the marketplace operator's admin UI. On success you get the REAL
# response (a promo bypasses payment, not product quality), tagged
# `via: "promo"`, with an empty settlement_tx_id (nothing was settled).
results = client.search_news("tinyman", promo_code="LAUNCH50", promo_wallet=client.address)
print(results.get("via"))  # "promo"

# A failed promo attempt (unknown/expired/exhausted/already-redeemed code,
# etc.) is a silent no-op server-side -- the call just falls through to a
# normal paid request, so it's always safe to pass promo_code speculatively.
```

Every paid method raises `PxkePaymentError` on failure -- check
`.settlement_tx_id` (set whenever the response body carried one) and
`.settled` (`True` when the payment went through even though the call was
refused for another reason, e.g. renewing a backup you don't own, or a scan
whose target URL refused the connection -- the caller supplied the URL, so
that fetch failure is charged, not refunded; a sandbox failure on PXke's side
is auto-refunded). A malformed request never reaches the payment gate at all
and raises the plain `PxkeHTTPError` instead -- nothing is charged. Calling a
paid method on a client with no mnemonic raises `PxkeConfigError`
immediately, before any network request.

## Changes

### 0.8.1 -- settlements feed, translated headlines

Added: `settlements()` for `GET /api/v1/x402/settlements` (the clarified name
of the recent-settlements feed: at most the 25 newest real, non-operator
settlements from the last 7 UTC days, newest first; the server takes no
query params, so there is no pagination) and `lang=` on `news()` (a
two-letter code; each headline's title/summary comes back in that stored
translation where one exists, English otherwise). Documented that the server
ignores `settlements_recent(limit=...)`. Nothing existing changed signature or
behaviour.

### 0.8.0 -- storage reads, versions, news tags

Added: the free, wallet-signature-authenticated storage routes
(`storage_list_backups`, `storage_get_backup`, `storage_delete_backup`,
`storage_list_versions`, `storage_get_version`), the paid
`storage_add_version`, the free `news_tags`, a `signer=` constructor option
(any object with `address` + `sign_bytes`), `WorkingAvmSigner.sign_bytes`,
`retention_days=` on `storage_create_backup`, and the `PxkeAuthError` /
`PxkeIntegrityError` exceptions. Nothing existing changed signature or
behaviour.

### 0.7.0 -- consolidation to the three products (breaking)

The marketplace was cut down to the products that do real work for a paying
agent (owner decision 2026-09-10). Removed from this client, with their
server routes: `search`, `board`, `features`, `grades`, `probe`,
`probe_history`, `file_feature_request`, `list_endpoint`, `place_on_board`,
`submit_grade`, `read_score`, `ping`, `uptime_check`, and every `social_*`
method. Added: `scan_url`. Fixed: `storage_renew_backup` now sends `wallet`
as the `?wallet=` query param the server actually reads (it was sent in the
JSON body, which the server ignored -- every renewal via 0.6.0 was a 404).

## Development

```bash
python -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest -q
python scripts/build_single_file.py > dist/pxke_x402.py   # regenerate the single-file build
python -m build --sdist                                  # dist/pxke_x402_client-<version>.tar.gz
```

Tests are fully offline: a fake `requests.Session` and a fake payment-client
seam stand in for the network and for the real signer/facilitator, so no
test ever spends money or needs a live wallet.

The signed-storage tests run the client against the server's own challenge
and verification code: when this package sits next to the PXke backend
checkout they import `backend/app/modules/x402_storage/services/auth_service.py`
directly (Redis faked); otherwise they use a copy of it in the test file.

## Publishing (not done by this package -- owner action)

This package has **not** been published to PyPI. To do so:

```bash
python -m build
twine upload dist/*
```

You'll need a PyPI account and an API token (`twine upload` will prompt, or
set `TWINE_USERNAME=__token__` and `TWINE_PASSWORD=<your token>`). The name
`pxke-x402-client` was not confirmed available on the public PyPI index at
the time this package was written -- this environment's `pip` is routed
through a private package proxy with no public-PyPI reachability, so
`pip index versions pxke-x402-client` could not be run against the real
index. Check `https://pypi.org/project/pxke-x402-client/` (or run
`pip index versions pxke-x402-client` from a machine with normal internet
access) before your first upload; if it's taken, bump `name` in
`pyproject.toml`.

## License

MIT (see `pyproject.toml`).
