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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ DEFAULT_POSTAGE_DURATION_HOURS=25
# Legacy: PLUR amount (for local backend)
DEFAULT_POSTAGE_AMOUNT=1000000000

# --- Free Tier Mode (optional) ---
# Use gateway free tier (rate-limited to 3 req/min, no wallet needed)
# FREE_TIER=true

# --- x402 Payment Configuration (optional) ---
# Enable x402 pay-per-request mode (USDC on Base chain)
# X402_ENABLED=true
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to this project will be documented in this file.

## [0.8.3] - 2026-03-03

### Added
- `--free` CLI flag and `FREE_TIER` env var for gateway free tier access (sends `X-Payment-Mode: free` header, rate-limited to 3 req/min) (#82)

## [0.8.2] - 2026-03-02

### Added
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ swarm-prov-upload x402 status
swarm-prov-upload x402 balance
swarm-prov-upload x402 info

# Upload with free tier (rate-limited, no wallet needed)
swarm-prov-upload --free upload --file /path/to/data.txt

# Upload with x402 enabled
swarm-prov-upload --x402 upload --file /path/to/data.txt

Expand Down Expand Up @@ -315,6 +318,9 @@ Uses python-dotenv for environment configuration:
- `DEFAULT_POSTAGE_DURATION_HOURS`: Stamp validity in hours (gateway only, default: 25)
- `DEFAULT_POSTAGE_AMOUNT`: Legacy PLUR amount for local backend (default: 1000000000)

**Free Tier Mode**:
- `FREE_TIER`: Use gateway free tier with `X-Payment-Mode: free` header (default: false, rate-limited to 3 req/min)

**x402 Payment Configuration**:
- `X402_ENABLED`: Enable x402 payment support (default: false)
- `X402_PRIVATE_KEY`: Wallet private key for signing payments
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ DEFAULT_POSTAGE_DURATION_HOURS=25 # Stamp validity in hours (gateway only, mi
DEFAULT_POSTAGE_AMOUNT=1000000000 # Legacy: for local backend
```

## Free Tier Mode (Optional)

For development and testing, use the gateway free tier — no wallet or payment configuration needed. Rate-limited to 3 requests/minute.

```bash
# Upload with free tier
swarm-prov-upload --free upload --file data.txt --std "PROV-STD-V1"

# Health check with free tier
swarm-prov-upload --free health

# Enable via environment variable
export FREE_TIER=true
swarm-prov-upload upload --file data.txt
```

| Environment Variable | Description | Default |
|---------------------|-------------|---------|
| `FREE_TIER` | Use gateway free tier (rate-limited) | `false` |

## x402 Payment Mode (Optional)

x402 enables pay-per-request payments using USDC on Base chain. When the gateway requires payment (HTTP 402), the CLI automatically handles the payment flow.
Expand Down Expand Up @@ -159,6 +179,7 @@ swarm-prov-upload x402 info

| Flag | Description |
|------|-------------|
| `--free` / `--no-free` | Enable/disable free tier mode |
| `--x402` / `--no-x402` | Enable/disable x402 for this command |
| `--auto-pay` / `--no-auto-pay` | Enable/disable auto-pay |
| `--max-pay FLOAT` | Maximum auto-pay amount in USD |
Expand Down Expand Up @@ -806,6 +827,9 @@ See [examples/README.md](examples/README.md) for the full guide with walkthrough
│ │ │ • provenance_standard: str? │ │ │ • DEFAULT_POSTAGE_AMOUNT │ │
│ │ │ • encryption: str? │ │ │ • .env file support │ │
│ │ └─────────────────────────────┘ │ │ │ │
│ │ │ │ Free Tier: │ │
│ │ │ │ • FREE_TIER │ │
│ │ │ │ │ │
│ │ │ │ x402 Configuration: │ │
│ │ x402 Payment Models: │ │ • X402_ENABLED │ │
│ │ • X402PaymentOption │ │ • X402_PRIVATE_KEY │ │
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "swarm-provenance-uploader"
version = "0.8.2"
version = "0.8.3"
description = "A CLI toolkit for wrapping data and uploading to Swarm."
readme = "README.md"
requires-python = ">=3.8"
Expand Down
2 changes: 1 addition & 1 deletion swarm_provenance_uploader/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import subprocess
from pathlib import Path

__version_base__ = "0.8.2"
__version_base__ = "0.8.3"


def _get_git_hash() -> str:
Expand Down
40 changes: 26 additions & 14 deletions swarm_provenance_uploader/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def _show_local_backend_warning():
"backend": config.BACKEND,
"gateway_url": config.GATEWAY_URL,
"bee_url": config.BEE_GATEWAY_URL,
"free_tier": config.FREE_TIER,
}

# Global state for x402 payment configuration
Expand Down Expand Up @@ -156,9 +157,10 @@ def _get_gateway_client_with_x402(gateway_url: str, verbose: bool = False) -> Ga
x402_auto_pay=_x402_config["auto_pay"],
x402_max_auto_pay_usd=_x402_config["max_auto_pay_usd"],
x402_payment_callback=_x402_payment_callback,
free_tier=_backend_config["free_tier"],
)
else:
return GatewayClient(base_url=gateway_url)
return GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])

@app.command()
def upload(
Expand Down Expand Up @@ -584,7 +586,7 @@ def download(
typer.echo(f"Fetching metadata from Swarm via {backend_url}...")
try:
if use_gateway:
gw_client = GatewayClient(base_url=gateway_url)
gw_client = GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])
metadata_bytes = gw_client.download_data(swarm_hash, verbose=verbose)
else:
metadata_bytes = swarm_client.download_data_from_swarm(local_bee_url, swarm_hash, verbose=verbose)
Expand Down Expand Up @@ -642,7 +644,7 @@ def download(
expected_address = None
if use_gateway:
try:
gw_client = GatewayClient(base_url=gateway_url)
gw_client = GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])
notary_info = gw_client.get_notary_info(verbose=verbose)
expected_address = notary_info.address
if verbose:
Expand Down Expand Up @@ -972,7 +974,7 @@ def stamps_list(
typer.echo(f"Listing stamps from {gateway_url}...")

try:
gw_client = GatewayClient(base_url=gateway_url)
gw_client = GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])
result = gw_client.list_stamps(verbose=verbose)

if not result.stamps:
Expand Down Expand Up @@ -1019,7 +1021,7 @@ def stamps_info(

try:
if use_gateway:
gw_client = GatewayClient(base_url=gateway_url)
gw_client = GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])
stamp = gw_client.get_stamp(stamp_id, verbose=verbose)
if not stamp:
typer.secho(f"Stamp {stamp_id} not found.", fg=typer.colors.YELLOW)
Expand Down Expand Up @@ -1075,7 +1077,7 @@ def stamps_extend(
typer.echo(f"Extending stamp {stamp_id} with amount {amount}...")

try:
gw_client = GatewayClient(base_url=gateway_url)
gw_client = GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])
result_id = gw_client.extend_stamp(stamp_id, amount, verbose=verbose)
typer.secho(f"SUCCESS: Stamp extended.", fg=typer.colors.GREEN)
typer.echo(f"Batch ID: {result_id}")
Expand All @@ -1100,7 +1102,7 @@ def stamps_pool_status(
typer.echo(f"Getting pool status from {gateway_url}...")

try:
gw_client = GatewayClient(base_url=gateway_url)
gw_client = GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])
status = gw_client.get_pool_status(verbose=verbose)

typer.echo(f"\nStamp Pool Status:")
Expand Down Expand Up @@ -1169,7 +1171,7 @@ def stamps_check(
typer.echo(f"Checking stamp health from {gateway_url}...")

try:
gw_client = GatewayClient(base_url=gateway_url)
gw_client = GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])
health = gw_client.check_stamp_health(stamp_id, verbose=verbose)

typer.echo(f"\nStamp Health Check:")
Expand Down Expand Up @@ -1235,7 +1237,7 @@ def wallet(
typer.echo(f"Getting wallet info from {gateway_url}...")

try:
gw_client = GatewayClient(base_url=gateway_url)
gw_client = GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])
wallet_info = gw_client.get_wallet(verbose=verbose)
typer.echo(f"\nWallet Information:")
typer.echo(f" Address: {wallet_info.walletAddress}")
Expand All @@ -1261,7 +1263,7 @@ def chequebook(
typer.echo(f"Getting chequebook info from {gateway_url}...")

try:
gw_client = GatewayClient(base_url=gateway_url)
gw_client = GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])
cheque_info = gw_client.get_chequebook(verbose=verbose)
typer.echo(f"\nChequebook Information:")
typer.echo(f" Address: {cheque_info.chequebookAddress}")
Expand Down Expand Up @@ -1296,7 +1298,7 @@ def health(
start_time = time_module.time()
try:
if use_gateway:
gw_client = GatewayClient(base_url=gateway_url)
gw_client = GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])
is_healthy = gw_client.health_check(verbose=verbose)
else:
# For local Bee, try to get stamps endpoint as health check
Expand Down Expand Up @@ -1476,7 +1478,7 @@ def notary_info(
typer.echo(f"Getting notary info from {gateway_url}...")

try:
gw_client = GatewayClient(base_url=gateway_url)
gw_client = GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])
info = gw_client.get_notary_info(verbose=verbose)

typer.echo(f"\nNotary Service:")
Expand Down Expand Up @@ -1525,7 +1527,7 @@ def notary_status(
typer.echo(f"Checking notary status from {gateway_url}...")

try:
gw_client = GatewayClient(base_url=gateway_url)
gw_client = GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])
status = gw_client.get_notary_status(verbose=verbose)

if status.available:
Expand Down Expand Up @@ -1603,7 +1605,7 @@ def notary_verify(
typer.echo(f"Fetching notary address from {gateway_url}...")

try:
gw_client = GatewayClient(base_url=gateway_url)
gw_client = GatewayClient(base_url=gateway_url, free_tier=_backend_config["free_tier"])
info = gw_client.get_notary_info(verbose=verbose)
expected_address = info.address
if not expected_address:
Expand Down Expand Up @@ -2386,6 +2388,10 @@ def main(
"--chain-rpc",
help="Custom RPC URL for blockchain connection."
)] = None,
free: Annotated[Optional[bool], typer.Option(
"--free",
help="Use gateway free tier (X-Payment-Mode: free, rate-limited)."
)] = None,
):
"""
Swarm Provenance CLI Toolkit - Wraps and uploads data to Swarm.
Expand All @@ -2395,6 +2401,8 @@ def main(

For pay-per-request mode, use --x402 to enable x402 payments.
Requires X402_PRIVATE_KEY environment variable.

For testing/development, use --free for rate-limited free tier access.
"""
if backend:
if backend not in ("gateway", "local"):
Expand All @@ -2418,6 +2426,10 @@ def main(
raise typer.Exit(code=1)
_x402_config["network"] = x402_network

# Free tier configuration
if free is not None:
_backend_config["free_tier"] = free

# Chain configuration
if chain:
if chain not in ("base-sepolia", "base"):
Expand Down
4 changes: 4 additions & 0 deletions swarm_provenance_uploader/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
# Custom RPC URL (optional, uses default if not set)
X402_RPC_URL = os.getenv("X402_RPC_URL")

# --- Free Tier Mode ---
# Sends X-Payment-Mode: free header (rate-limited to 3 req/min)
FREE_TIER = os.getenv("FREE_TIER", "false").lower() == "true"

# --- Chain / Blockchain Configuration ---
# Enable on-chain anchoring (disabled by default)
CHAIN_ENABLED = os.getenv("CHAIN_ENABLED", "false").lower() == "true"
Expand Down
15 changes: 13 additions & 2 deletions swarm_provenance_uploader/core/gateway_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def __init__(
x402_auto_pay: bool = False,
x402_max_auto_pay_usd: float = 1.00,
x402_payment_callback: Optional[Callable[[str, str], bool]] = None,
free_tier: bool = False,
):
"""
Initialize the gateway client.
Expand All @@ -80,9 +81,11 @@ def __init__(
x402_max_auto_pay_usd: Maximum auto-pay amount in USD
x402_payment_callback: Optional callback for payment confirmation.
Called with (amount_usd, description) -> bool
free_tier: Send X-Payment-Mode: free header (rate-limited)
"""
self.base_url = (base_url or os.getenv("PROVENANCE_GATEWAY_URL", self.DEFAULT_URL)).rstrip("/")
self.api_key = api_key or os.getenv("PROVENANCE_GATEWAY_API_KEY")
self.free_tier = free_tier

# x402 configuration
self.x402_enabled = x402_enabled
Expand All @@ -108,6 +111,8 @@ def _get_headers(self) -> dict:
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
if self.free_tier:
headers["X-Payment-Mode"] = "free"
return headers

def _make_url(self, path: str) -> str:
Expand Down Expand Up @@ -145,13 +150,13 @@ def _handle_402_response(
amounts = [opt.get("maxAmountRequired", "?") for opt in accepts]
raise PaymentRequiredError(
f"Payment required (amounts: {amounts}). "
"Enable x402 with --x402 flag or X402_ENABLED=true",
"Enable x402 with --x402 flag or use --free for free tier",
payment_options=accepts,
)
except (ValueError, KeyError):
pass
raise PaymentRequiredError(
"Payment required. Enable x402 with --x402 flag or X402_ENABLED=true"
"Payment required. Enable x402 with --x402 flag or use --free for free tier"
)

x402_client = self._get_x402_client()
Expand Down Expand Up @@ -526,6 +531,8 @@ def upload_data(
headers = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
if self.free_tier:
headers["X-Payment-Mode"] = "free"

# Use _make_paid_request for x402 support
response = self._make_paid_request(
Expand Down Expand Up @@ -987,6 +994,8 @@ def upload_data_with_signing(
headers = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
if self.free_tier:
headers["X-Payment-Mode"] = "free"

# Use _make_paid_request for x402 support
response = self._make_paid_request(
Expand Down Expand Up @@ -1108,6 +1117,8 @@ def upload_manifest(
headers = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
if self.free_tier:
headers["X-Payment-Mode"] = "free"

response = self._make_paid_request(
"POST",
Expand Down
Loading