From eda5555f496c8b357c4361b8afb79f6d3bbdbc94 Mon Sep 17 00:00:00 2001 From: Crt Ahlin Date: Tue, 3 Mar 2026 17:46:26 +0100 Subject: [PATCH] Add --free flag for gateway free tier access (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FREE_TIER env var and --free CLI flag that sends X-Payment-Mode: free header to the gateway, enabling rate-limited (3 req/min) access without wallet setup. Bump version 0.8.2 → 0.8.3. --- .env.example | 4 ++ CHANGELOG.md | 5 ++ CLAUDE.md | 6 +++ README.md | 24 +++++++++ pyproject.toml | 2 +- swarm_provenance_uploader/__init__.py | 2 +- swarm_provenance_uploader/cli.py | 40 +++++++++----- swarm_provenance_uploader/config.py | 4 ++ .../core/gateway_client.py | 15 +++++- tests/test_cli.py | 49 ++++++++++++++++- tests/test_gateway_client.py | 53 +++++++++++++++++++ 11 files changed, 184 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 3fa9779..c6d8394 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 227baa8..0834076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 5774e3e..d49ab60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/README.md b/README.md index b86cde9..a22b01e 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 | @@ -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 │ │ diff --git a/pyproject.toml b/pyproject.toml index ebc709a..00919a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/swarm_provenance_uploader/__init__.py b/swarm_provenance_uploader/__init__.py index bdf74a7..29a019f 100644 --- a/swarm_provenance_uploader/__init__.py +++ b/swarm_provenance_uploader/__init__.py @@ -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: diff --git a/swarm_provenance_uploader/cli.py b/swarm_provenance_uploader/cli.py index 75d2605..b2112ed 100644 --- a/swarm_provenance_uploader/cli.py +++ b/swarm_provenance_uploader/cli.py @@ -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 @@ -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( @@ -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) @@ -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: @@ -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: @@ -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) @@ -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}") @@ -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:") @@ -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:") @@ -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}") @@ -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}") @@ -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 @@ -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:") @@ -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: @@ -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: @@ -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. @@ -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"): @@ -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"): diff --git a/swarm_provenance_uploader/config.py b/swarm_provenance_uploader/config.py index e2f5aba..c1ed5ec 100644 --- a/swarm_provenance_uploader/config.py +++ b/swarm_provenance_uploader/config.py @@ -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" diff --git a/swarm_provenance_uploader/core/gateway_client.py b/swarm_provenance_uploader/core/gateway_client.py index 99d241a..1cc0d0b 100644 --- a/swarm_provenance_uploader/core/gateway_client.py +++ b/swarm_provenance_uploader/core/gateway_client.py @@ -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. @@ -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 @@ -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: @@ -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() @@ -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( @@ -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( @@ -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", diff --git a/tests/test_cli.py b/tests/test_cli.py index df3683e..0ff56ee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,6 +25,7 @@ def reset_backend_config(): _backend_config["backend"] = "gateway" _backend_config["gateway_url"] = "https://provenance-gateway.datafund.io" _backend_config["bee_url"] = "http://localhost:1633" + _backend_config["free_tier"] = False _x402_config["enabled"] = False _x402_config["auto_pay"] = False _x402_config["max_auto_pay_usd"] = 1.00 @@ -41,6 +42,7 @@ def reset_backend_config(): _backend_config["backend"] = "gateway" _backend_config["gateway_url"] = "https://provenance-gateway.datafund.io" _backend_config["bee_url"] = "http://localhost:1633" + _backend_config["free_tier"] = False _x402_config["enabled"] = False _x402_config["auto_pay"] = False _x402_config["max_auto_pay_usd"] = 1.00 @@ -531,7 +533,7 @@ def test_custom_gateway_url(self, mocker): assert result.exit_code == 0 # Verify custom URL was used - mock_constructor.assert_called_with(base_url="https://custom.gateway.io") + mock_constructor.assert_called_with(base_url="https://custom.gateway.io", free_tier=False) # ============================================================================= @@ -3767,4 +3769,47 @@ def test_gas_flag_on_access(self, mocker): result = runner.invoke(app, ["chain", "access", DUMMY_SWARM_REF, "--gas", "300000"]) assert result.exit_code == 0, f"CLI Failed: {result.stdout}" - assert _chain_config["gas_limit"] == 300000 \ No newline at end of file + assert _chain_config["gas_limit"] == 300000 + + +# ============================================================================= +# FREE TIER FLAG TESTS +# ============================================================================= + +class TestFreeTierFlag: + """Tests for --free flag.""" + + def test_free_flag_sets_backend_config(self, mocker): + """Tests that --free flag sets _backend_config['free_tier'].""" + mock_client = mocker.MagicMock() + mock_client.health_check.return_value = True + + mocker.patch( + "swarm_provenance_uploader.cli.GatewayClient", + return_value=mock_client, + ) + + result = runner.invoke(app, ["--free", "health"]) + + assert result.exit_code == 0, f"CLI Failed: {result.stdout}" + assert _backend_config["free_tier"] is True + + def test_free_flag_default_is_false(self): + """Tests that free_tier defaults to False.""" + assert _backend_config["free_tier"] is False + + def test_free_and_x402_both_set(self, mocker): + """Tests that --free and --x402 can both be set (free header sent, x402 also configured).""" + mock_client = mocker.MagicMock() + mock_client.health_check.return_value = True + + mock_gw_cls = mocker.patch( + "swarm_provenance_uploader.cli.GatewayClient", + return_value=mock_client, + ) + + result = runner.invoke(app, ["--free", "--x402", "health"]) + + assert result.exit_code == 0, f"CLI Failed: {result.stdout}" + assert _backend_config["free_tier"] is True + assert _x402_config["enabled"] is True \ No newline at end of file diff --git a/tests/test_gateway_client.py b/tests/test_gateway_client.py index 99c0efa..b7d8d10 100644 --- a/tests/test_gateway_client.py +++ b/tests/test_gateway_client.py @@ -1226,3 +1226,56 @@ def test_upload_manifest_deferred_and_redundancy(self, requests_mock, tmp_path): url = mock.last_request.url.lower() assert "deferred=true" in url assert "redundancy=true" in url + + +class TestGatewayClientFreeTier: + """Tests for free tier header functionality.""" + + def test_free_tier_constructor(self): + """Tests that free_tier parameter is stored correctly.""" + client = GatewayClient(base_url="https://test.gateway.io", free_tier=True) + assert client.free_tier is True + + client2 = GatewayClient(base_url="https://test.gateway.io", free_tier=False) + assert client2.free_tier is False + + def test_free_tier_disabled_by_default(self): + """Tests that free_tier is disabled by default.""" + client = GatewayClient(base_url="https://test.gateway.io") + assert client.free_tier is False + + def test_free_tier_header_in_get_headers(self): + """Tests that free_tier=True adds X-Payment-Mode: free header.""" + client = GatewayClient(base_url="https://test.gateway.io", free_tier=True) + headers = client._get_headers() + assert headers.get("X-Payment-Mode") == "free" + + def test_free_tier_header_not_set_by_default(self): + """Tests that free_tier=False does not add X-Payment-Mode header.""" + client = GatewayClient(base_url="https://test.gateway.io", free_tier=False) + headers = client._get_headers() + assert "X-Payment-Mode" not in headers + + def test_free_tier_header_in_upload_data(self, requests_mock): + """Tests that free tier header is sent in upload_data requests.""" + adapter = requests_mock.post( + "https://test.gateway.io/api/v1/data/", + json={"reference": DUMMY_SWARM_REF}, + ) + + client = GatewayClient(base_url="https://test.gateway.io", free_tier=True) + client.upload_data(data=b"test data", stamp_id=DUMMY_STAMP) + + assert adapter.last_request.headers.get("X-Payment-Mode") == "free" + + def test_free_tier_header_not_in_upload_when_disabled(self, requests_mock): + """Tests that free tier header is NOT sent when disabled.""" + adapter = requests_mock.post( + "https://test.gateway.io/api/v1/data/", + json={"reference": DUMMY_SWARM_REF}, + ) + + client = GatewayClient(base_url="https://test.gateway.io", free_tier=False) + client.upload_data(data=b"test data", stamp_id=DUMMY_STAMP) + + assert "X-Payment-Mode" not in adapter.last_request.headers