From 777bc663f04299d4a7ac6c0a7ea04267949e0f50 Mon Sep 17 00:00:00 2001 From: Crt Ahlin Date: Sun, 30 Aug 2026 08:08:53 +0200 Subject: [PATCH 1/2] Write down where configuration lives and when a change takes effect (#314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every one of these has cost time, and the shape is always identical: a change is made, something reports success, and nothing happens. A table of every configuration surface — GitHub variables, the two env files, the host env file, config.alloy, the Caddyfile, the Grafana JSON — saying when a change reaches the running system and whether a deploy overwrites it. Then the three that are genuinely surprising. docker restart does not reload env_file. Docker reads it when it CREATES a container, not when it starts one. Editing /opt/swarm_connect.env and restarting changes nothing, and the container reports healthy on the old configuration. That cost an incident: the pool target was lowered, the container restarted, and it kept the old target of five while holding two — reporting low_reserve_warning true and preparing to buy three replacements, the exact opposite of the change being made. A bind-mounted single FILE pins its inode. git pull replaces the file rather than editing it, so the container keeps reading the old one, and compose correctly does nothing because the service definition is unchanged. Changes to config.alloy were silently dead from 2026-08-25 until #301. Not everything in the repo is deployed. deploy/Caddyfile and the Grafana JSON are version controlled for review and history; nothing applies them. They drift, and overwriting the live Caddyfile from the repo without diffing first once reintroduced tls internal and broke TLS for two minutes. The rule it lands on: changing a setting takes BOTH a gh variable set, so the next deploy keeps it, AND an env file edit plus force-recreate, so it applies now. Only the second and the next deploy reverts it; only the first and nothing changes until someone deploys. Docs only. Full suite: 1075 passed, 25 skipped. --- CLAUDE.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 9d3b3e9..9760f26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -555,6 +555,69 @@ feature branches → dev → main - NEVER use `crtahlin/swarm_connect` - that is the upstream fork, not the main repo - Use `git remote -v` to verify remotes if unsure +## Configuration: where it lives, and when a change actually takes effect + +Every one of these has bitten us. The pattern is always the same — a change is +made, something reports success, and nothing happens. + +| what you change | reaches the running system when | overwritten by a deploy? | +|---|---|---| +| GitHub environment variable (`vars.*`) | next deploy writes it into the env file | n/a — this is the source of truth | +| `/opt/swarm_connect.env`, `/opt/swarm_connect_dev.env` | **container is RECREATED** — not on restart | **yes**, rewritten from GitHub vars | +| `/opt/swarm_connect_host.env` | next deploy appends it to `.env` | no — deploy only ever appends it | +| `monitoring/alloy/config.alloy` | Alloy container is recreated | yes, but see below | +| `deploy/Caddyfile` | **never automatically** — copy to `/etc/caddy/` and `systemctl reload caddy` | no | +| `monitoring/alerting/*.json`, dashboards | **never automatically** — run `scripts/apply_grafana.py` | no | + +### `docker restart` does not reload `env_file` + +Docker reads `env_file` when it **creates** a container, not when it starts one. +So editing `/opt/swarm_connect.env` and running `docker restart` changes nothing, +and the container reports healthy while running the old configuration. + +```bash +# WRONG — silently keeps the old values +docker restart swarm_connect-provenance_gateway-1 + +# RIGHT +cd /opt/swarm_connect && docker compose up -d --force-recreate --no-deps provenance_gateway +``` + +This cost a real incident: the stamp pool target was lowered, the container was +restarted, and it kept the old target of five while the pool held two — reporting +`low_reserve_warning: true` and preparing to buy three replacements, the exact +opposite of the change being made. + +### Changing a setting properly + +Both, in this order, or the change is temporary: + +1. `gh variable set NAME --repo datafund/swarm_connect --env production --body VALUE` + — so the next deploy keeps it. +2. Edit `/opt/swarm_connect.env` and **force-recreate** the container — so it + applies now rather than at the next deploy. + +Doing only (2) means the next deploy reverts it. Doing only (1) means nothing +changes until someone deploys. + +### A bind-mounted FILE pins its inode + +`config.alloy` is mounted as a single file. A file bind mount pins the inode, and +`git pull` **replaces** the file rather than editing it, so the container goes on +reading the old one indefinitely. Compose does not help: the service definition is +unchanged, so `up -d` correctly does nothing. + +Changes to it were silently dead from 2026-08-25 until #301, which force-recreates +Alloy on every deploy. If you add another single-file mount, it will need the same. + +### Not everything in the repo is deployed + +`deploy/Caddyfile` and `monitoring/alerting/alert-rules.json` are version +controlled for review and history. Nothing applies them. They can drift from what +is running, and have. Diff before assuming, and never overwrite the live +`/etc/caddy/Caddyfile` from the repo without diffing first — doing that once +reintroduced `tls internal` and broke TLS for two minutes. + ## Deployment Workflow ### Auto-Deployment Triggers From 451cf4cf38a46aac08b5349c239ea1e5977dc02f Mon Sep 17 00:00:00 2001 From: Crt Ahlin Date: Sun, 30 Aug 2026 08:09:08 +0200 Subject: [PATCH 2/2] Stop strangers storing data on batches the gateway paid for (#313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop strangers storing data on batches the gateway paid for check_access() allowed any caller to use a batch absent from the ownership registry, commented "backward compatibility" for batches predating it. The set that default actually covered was not legacy callers. Every path by which a caller obtains a batch already registers it — /pool/acquire, POST /stamps/, /stamps/for-owner. What it covered was the POOL'S OWN INVENTORY: ownership was recorded when a caller ACQUIRED a batch, never when the pool bought one, so everything sitting in the pool was untracked and therefore writable by anyone who knew its id. Batch ids are not secret; GET /stamps/ lists them. A production pool batch is 50% utilised having never been acquired. That is capacity the gateway funded, consumed by someone who never asked for it, on batches it pays roughly 0.018-0.14 BZZ a day each to keep alive. Two changes, in the order the fix requires: Pool inventory is registered as POOL_OWNER at purchase (add_stamp_to_pool) and adopted on sync, so batches bought before this change are protected too rather than staying open for the rest of their lives. Registration failure never fails a purchase — the batch exists and was paid for — but logs that it is unprotected until registered. check_access then refuses POOL_OWNER outright, and refuses untracked batches instead of allowing them. Acquiring re-registers the batch to the caller, which is the only way to get one. STAMP_OWNERSHIP_ALLOW_UNTRACKED restores the old behaviour, for one situation: STAMP_OWNERSHIP_FILE is lost, every batch becomes untracked at once, and legitimate owners would be locked out of batches they paid for. A test pins that permissive mode does NOT reach pool inventory — recovering from a lost registry must not reopen the hole it is recovering from. tests/test_stamp_ownership.py::test_untracked_stamp_allowed asserted the old behaviour and now asserts the new one. Three tests added: permissive mode, pool inventory refused to paid/free/anonymous callers alike, and permissive mode not unlocking pool inventory. Full suite: 1078 passed, 25 skipped. Closes #312. * Test that the pool claims what it buys, not just that the check works The enforcement tests construct registry state by hand, so they pass whether or not any code registers anything. Nothing asserted that add_stamp_to_pool or sync_from_bee_node call register_stamp at all — meaning the lock was tested and the door was not, which is the same asymmetry that let #312 stay open. Three tests: a purchased batch is registered to POOL_OWNER with source pool_purchase a registration failure does not lose a batch already paid for sync adopts pre-existing inventory, so batches bought before this change are protected rather than staying open for the rest of their lives The second matters because the batch exists on chain regardless: raising there would drop it from the pool while the money is spent, which is worse than an unprotected batch. It is logged instead, and the log says it is unprotected. The sync test initially failed with synced == 0 — the fixture omitted `local` and `usable`, which sync filters on. Fixed by matching the record shape the other sync tests use. TEST_STRATEGY.md records why both halves are needed. Full suite: 1081 passed, 25 skipped. * Report gateway inventory as 'pool', not as free for anyone to use Found reviewing this PR. accessMode is computed as: "owned" if ownership_info.get("mode") == "paid" else "shared" Pool inventory registers with mode="pool", which is not "paid", so it fell through to "shared" — the value that tells a client the batch is free for anyone to use, about exactly the batches check_access now refuses. A client reading the listing would believe it and be denied on upload, which is worse than the original defect in one respect: the API would be actively misleading rather than merely permissive. Neither existing value was honest. "shared" invites use; null means "unknown to the registry", which it is not. So the Literal gains "pool" and the field description says how to obtain one — acquiring re-registers it to the caller. Two tests: the owner-to-accessMode mapping for all three cases, and that the Literal permits "pool" (it is validated at serialisation, so an unlisted value would fail at runtime rather than at import). provenance-smasher asserts accessMode membership against a fixed list and would have failed on every pool batch. Covered by provenance-smasher#31, which must merge before this deploys. Full suite: 1083 passed, 25 skipped. --- .env.example | 6 ++ CLAUDE.md | 3 + TEST_STRATEGY.md | 16 +++++ app/api/models/stamp.py | 9 ++- app/core/config.py | 7 +++ app/services/stamp_ownership.py | 35 ++++++++++- app/services/stamp_pool.py | 63 +++++++++++++++++++- app/services/swarm_api.py | 15 ++++- tests/test_stamp_ownership.py | 100 ++++++++++++++++++++++++++++++-- tests/test_stamp_pool.py | 82 ++++++++++++++++++++++++++ 10 files changed, 325 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 3d89b1a..8c15ef2 100644 --- a/.env.example +++ b/.env.example @@ -163,6 +163,12 @@ POOL_ADMIN_ADDRESSES= # STAMP_POOL_IMMEDIATE_REPLENISH=true # Purchase replacement immediately on release (default: true) # STAMP_POOL_STATE_FILE=data/pool_state.json # State file for pool persistence across restarts # STAMP_OWNERSHIP_FILE=data/stamp_owners.json # Stamp ownership tracking for x402 enforcement + +# May any caller use a batch that is not in the ownership registry? +# false (default) fails closed. Set true only to recover from a lost +# STAMP_OWNERSHIP_FILE, which would otherwise lock owners out of batches +# they paid for — re-register, then set it back. +STAMP_OWNERSHIP_ALLOW_UNTRACKED=false # # Note: Pool will auto-purchase stamps when reserve is low. # Ensure Bee node wallet has sufficient xBZZ and xDAI. diff --git a/CLAUDE.md b/CLAUDE.md index 9760f26..bf2f67e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,9 @@ CORS (browser access): - `estimatedReadyAt`: ISO 8601 timestamp when stamp should be usable (null for external stamps) - `propagationStatus`: `"ready"` / `"propagating"` / `"unknown"` (null if undetermined) +**Stamp ownership enforcement** (`app/services/stamp_ownership.py`, when `X402_ENABLED`): +Every batch a caller can obtain is registered to them — pool acquire, direct purchase, and for-owner all call `register_stamp`. Batches the pool buys for its own inventory are registered as `POOL_OWNER` (`"pool"`) at purchase and on sync, and `check_access` **refuses** them: a caller receives one by acquiring it, which re-registers it to them. A batch absent from the registry is also refused; `STAMP_OWNERSHIP_ALLOW_UNTRACKED=true` restores the old permissive default and exists solely to recover from a lost registry file. Before #312 the pool's inventory was untracked and the untracked default was *allow*, so anyone could store data on batches the gateway had paid for — one production batch reached 50% utilisation without ever being acquired. + **Access mode field** (included in all stamp responses): - `accessMode`: `"owned"` (exclusive to a wallet via x402), `"shared"` (free tier), or `null` (not tracked) diff --git a/TEST_STRATEGY.md b/TEST_STRATEGY.md index ed005c3..d54fc24 100644 --- a/TEST_STRATEGY.md +++ b/TEST_STRATEGY.md @@ -285,3 +285,19 @@ usable stamps. Reachability is not a gate. Whether production is up says nothing about whether this run intended to touch it. And opting in to live tests is not the same decision as opting in to spending, which is why the purchase has its own switch. + +## Stamp ownership: test the lock AND the door + +`tests/test_stamp_ownership.py` covers enforcement — untracked batches refused, +pool-owned batches refused to paid, free-tier and anonymous callers alike, and +the `STAMP_OWNERSHIP_ALLOW_UNTRACKED` escape hatch not reaching pool inventory. + +`tests/test_stamp_pool.py::TestPoolInventoryIsRegisteredAsOwned` covers the other +half: that the pool actually registers what it buys, that a registration failure +does not lose a batch already paid for, and that sync adopts inventory bought +before the change. + +Both halves are needed, and the second is the one easy to omit. Enforcement tests +construct registry state by hand, so they pass whether or not any code registers +anything. That asymmetry is how #312 stayed open: the check was fine, nothing +was claiming ownership of the pool's own batches, and every test agreed. diff --git a/app/api/models/stamp.py b/app/api/models/stamp.py index 2e3bbba..67366f2 100644 --- a/app/api/models/stamp.py +++ b/app/api/models/stamp.py @@ -66,9 +66,14 @@ class StampDetails(BaseModel): ) # --- Access Control Fields --- - accessMode: Optional[Literal["owned", "shared"]] = Field( + accessMode: Optional[Literal["owned", "shared", "pool"]] = Field( None, - description="Access mode: 'owned' (exclusive to a wallet via x402 payment), 'shared' (free tier, anyone can use), null (not in ownership registry)." + description=( + "Access mode: 'owned' (exclusive to a wallet via x402 payment), " + "'shared' (free tier, anyone can use), 'pool' (gateway inventory — " + "not usable directly; acquire it via POST /api/v1/pool/acquire, which " + "transfers ownership to you), null (not in ownership registry)." + ) ) # --- Calculated Fields --- diff --git a/app/core/config.py b/app/core/config.py index c2f1764..b41dfe2 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -85,6 +85,13 @@ class Settings(BaseSettings): # Stamp ownership: file path for persisting stamp ownership records STAMP_OWNERSHIP_FILE: str = "data/stamp_owners.json" + # When a batch is absent from the ownership registry, may any caller use it? + # + # False (the default) fails closed. True restores the pre-#312 behaviour and + # exists for one situation: the registry file is lost, every batch becomes + # untracked at once, and legitimate owners would be locked out of batches + # they paid for. Turn it on to recover, re-register, and turn it off again. + STAMP_OWNERSHIP_ALLOW_UNTRACKED: bool = False # === Debug Proxy (read-only Bee diagnostics, signature-gated) === # Comma-separated 0x addresses allowed to read Bee diagnostics via diff --git a/app/services/stamp_ownership.py b/app/services/stamp_ownership.py index 2bdb5b6..3a16f7e 100644 --- a/app/services/stamp_ownership.py +++ b/app/services/stamp_ownership.py @@ -19,6 +19,12 @@ logger = logging.getLogger(__name__) +# Owner value for batches the gateway bought for its own pool and has not handed +# out. Not a wallet address, and deliberately not a valid one, so it can never +# collide with a real owner. +POOL_OWNER = "pool" + + class StampOwnershipManager: """ Tracks stamp ownership and enforces access control. @@ -116,9 +122,34 @@ def check_access( with self._lock: entry = self._registry.get(batch_id) - # Stamp not in registry -> allowed (backward compatibility) + # Stamp not in registry. + # + # This used to return allowed, for backward compatibility with batches + # predating the registry. The set that default actually covered was not + # legacy callers: every path by which a caller obtains a batch — + # /pool/acquire, POST /stamps/, /stamps/for-owner — registers it. What it + # covered was the POOL'S OWN INVENTORY, bought and funded by the gateway + # and not yet handed to anyone, which is precisely what no caller should + # be writing to. A production pool batch reached 50% utilisation without + # ever being acquired (#312). + # + # Pool inventory is now registered at purchase time as POOL_OWNER, so the + # untracked set is empty in normal operation and denying it costs nothing. + # + # STAMP_OWNERSHIP_ALLOW_UNTRACKED restores the old behaviour. It exists + # for one specific situation: STAMP_OWNERSHIP_FILE is lost or reset, every + # batch becomes untracked at once, and legitimate owners would otherwise + # be locked out of batches they paid for. Failing closed is right by + # default; this is the deliberate escape hatch, not a shrug. if entry is None: - return True, "stamp not tracked, backward compatibility" + if settings.STAMP_OWNERSHIP_ALLOW_UNTRACKED: + return True, "stamp not tracked, permissive mode enabled" + return False, "stamp is not registered to any owner" + + # Gateway-owned pool inventory. Nobody may write to it directly — a + # caller receives one by acquiring it, which re-registers it to them. + if entry["owner"] == POOL_OWNER: + return False, "stamp belongs to the gateway pool; acquire it first" # Shared stamps -> always allowed if entry["owner"] == "shared": diff --git a/app/services/stamp_pool.py b/app/services/stamp_pool.py index e4e5715..315ea8e 100644 --- a/app/services/stamp_pool.py +++ b/app/services/stamp_pool.py @@ -411,7 +411,55 @@ def add_stamp_to_pool(self, batch_id: str, depth: int, amount: int, ttl: int, la self._pool[batch_id] = stamp logger.info(f"Added stamp {batch_id[:16]}... to pool (depth={depth})") self._save_state() - return stamp + + # Register as gateway-owned, outside the lock — the ownership manager + # takes its own. + # + # Ownership used to be recorded only when a caller ACQUIRED a batch, so + # everything sitting in the pool was untracked, and check_access let any + # caller write to an untracked batch. A production pool batch reached 50% + # utilisation without ever being acquired: capacity the gateway had paid + # for, consumed by someone who never asked for it (#312). + # + # Registering at purchase closes the window entirely — the batch is owned + # from the moment it exists. Acquiring it re-registers it to the caller. + try: + from app.services.stamp_ownership import POOL_OWNER, stamp_ownership_manager + stamp_ownership_manager.register_stamp( + batch_id=batch_id, + owner=POOL_OWNER, + mode="pool", + source="pool_purchase", + ) + except Exception as e: + # Never fail a purchase over bookkeeping — the batch exists and was + # paid for. Log loudly: until it is registered it is writable by + # anyone, which is the whole point of this change. + logger.error( + f"Failed to register pool ownership for {batch_id[:16]}...: {e}. " + "The batch is unprotected until registered." + ) + + return stamp + + def _register_pool_ownership(self, batch_ids: Set[str]) -> None: + """Record pool-held batches as gateway-owned, so nobody may write to them.""" + if not batch_ids: + return + try: + from app.services.stamp_ownership import POOL_OWNER, stamp_ownership_manager + for batch_id in batch_ids: + stamp_ownership_manager.register_stamp( + batch_id=batch_id, + owner=POOL_OWNER, + mode="pool", + source="pool_sync", + ) + except Exception as e: + logger.error( + f"Failed to register pool ownership for {len(batch_ids)} batch(es): {e}. " + "They are unprotected until registered." + ) async def sync_from_bee_node(self) -> int: """ @@ -504,6 +552,19 @@ async def sync_from_bee_node(self) -> int: if valid_ids != known_ids: self._save_state(extra_ids=unreadable_ids) + # Adopt everything in the pool as gateway-owned. + # + # Registering at purchase (add_stamp_to_pool) only protects batches + # bought from now on. Batches already held predate that and would + # stay untracked — and therefore writable by anyone — for as long as + # they live, which is exactly the state that let a production batch + # reach 50% utilisation unasked (#312). + # + # Idempotent, and does not disturb a batch already owned by someone: + # only AVAILABLE batches are in the pool, and one acquired by a caller + # was removed from it at release. + self._register_pool_ownership(valid_ids) + self._last_sync_ok = True return synced_count diff --git a/app/services/swarm_api.py b/app/services/swarm_api.py index 3b668b5..e951bda 100644 --- a/app/services/swarm_api.py +++ b/app/services/swarm_api.py @@ -9,7 +9,7 @@ from app.core.config import settings from app.services.http_client import get_client -from app.services.stamp_ownership import stamp_ownership_manager +from app.services.stamp_ownership import POOL_OWNER, stamp_ownership_manager from app.services.metrics import bee_api_errors_total logger = logging.getLogger(__name__) @@ -588,7 +588,18 @@ async def get_all_stamps_processed() -> List[Dict[str, Any]]: # Determine access mode from ownership registry ownership_info = stamp_ownership_manager.get_stamp_info(batch_id) if ownership_info: - access_mode = "owned" if ownership_info.get("mode") == "paid" else "shared" + owner = ownership_info.get("owner") + if owner == POOL_OWNER: + # Gateway inventory. Reported distinctly because the + # alternatives both lie: "shared" tells a client it may use + # the batch, which check_access refuses, and null means + # "unknown to the registry", which it is not. A caller gets + # one of these by acquiring it, which re-registers it to them. + access_mode = "pool" + elif ownership_info.get("mode") == "paid": + access_mode = "owned" + else: + access_mode = "shared" else: access_mode = None diff --git a/tests/test_stamp_ownership.py b/tests/test_stamp_ownership.py index a1b73a2..50a8171 100644 --- a/tests/test_stamp_ownership.py +++ b/tests/test_stamp_ownership.py @@ -120,7 +120,11 @@ def test_shared_stamp_access_no_x402(self, manager): class TestBackwardCompatibility: - """Test backward compatibility for untracked stamps.""" + """Untracked batches, and the gateway's own inventory. + + Named for the behaviour it used to protect. That behaviour is now inverted + (#312) and these tests hold the line in the other direction. + """ @pytest.fixture def state_file(self, tmp_path): @@ -130,13 +134,66 @@ def state_file(self, tmp_path): def manager(self, state_file): return StampOwnershipManager(state_file=state_file) - def test_untracked_stamp_allowed(self, manager): - """Stamp not in registry is allowed (backward compat for pre-existing stamps).""" + def test_untracked_stamp_denied(self, manager): + """A batch nobody owns is refused, not waved through. + + This asserted the opposite until #312. The permissive default was there + for batches predating the registry, but the set it actually covered was + the pool's own inventory: every path a caller can obtain a batch through + registers it, and the pool did not register what it bought. A production + pool batch reached 50% utilisation without ever being acquired. + """ + with patch('app.services.stamp_ownership.settings') as mock_settings: + mock_settings.X402_ENABLED = True + mock_settings.STAMP_OWNERSHIP_ALLOW_UNTRACKED = False + allowed, reason = manager.check_access("unknown_stamp", "0xAnyWallet", "paid") + assert allowed is False, "an unowned batch was writable by an arbitrary wallet" + assert "not registered" in reason + + def test_untracked_stamp_allowed_in_permissive_mode(self, manager): + """The escape hatch, for when the registry file is lost. + + If STAMP_OWNERSHIP_FILE disappears, every batch becomes untracked at once + and failing closed would lock legitimate owners out of batches they paid + for. This exists to recover from that, not as a default. + """ with patch('app.services.stamp_ownership.settings') as mock_settings: mock_settings.X402_ENABLED = True + mock_settings.STAMP_OWNERSHIP_ALLOW_UNTRACKED = True allowed, reason = manager.check_access("unknown_stamp", "0xAnyWallet", "paid") assert allowed is True - assert "backward compatibility" in reason + assert "permissive" in reason + + def test_pool_owned_stamp_is_not_writable(self, manager): + """Gateway inventory is refused to everyone, including the free tier. + + A caller receives a pool batch by acquiring it, which re-registers it to + them. Writing to one directly bypasses that and spends capacity the + gateway funded. + """ + from app.services.stamp_ownership import POOL_OWNER + manager.register_stamp("pool_stamp", owner=POOL_OWNER, mode="pool", source="pool_purchase") + with patch('app.services.stamp_ownership.settings') as mock_settings: + mock_settings.X402_ENABLED = True + mock_settings.STAMP_OWNERSHIP_ALLOW_UNTRACKED = False + for wallet, mode in (("0xSomeone", "paid"), (None, "free-tier"), (None, None)): + allowed, reason = manager.check_access("pool_stamp", wallet, mode) + assert allowed is False, f"pool inventory was writable by {wallet or 'anonymous'} ({mode})" + assert "acquire it first" in reason + + def test_permissive_mode_does_not_unlock_pool_inventory(self, manager): + """The escape hatch is for untracked batches, not for owned ones. + + Pool inventory IS tracked, so permissive mode must not reach it — + otherwise recovering from a lost registry would reopen the hole. + """ + from app.services.stamp_ownership import POOL_OWNER + manager.register_stamp("pool_stamp", owner=POOL_OWNER, mode="pool", source="pool_purchase") + with patch('app.services.stamp_ownership.settings') as mock_settings: + mock_settings.X402_ENABLED = True + mock_settings.STAMP_OWNERSHIP_ALLOW_UNTRACKED = True + allowed, _ = manager.check_access("pool_stamp", "0xSomeone", "paid") + assert allowed is False, "permissive mode unlocked gateway-owned inventory" def test_x402_disabled_skips_enforcement(self, manager): """No ownership checks when x402 is off.""" @@ -385,3 +442,38 @@ def test_direct_purchase_registers_stamp(self, client): if call_args[1]: assert call_args[1].get("batch_id") == "new_batch_id_123" assert call_args[1].get("source") == "direct_purchase" + + +class TestPoolInventoryIsReportedDistinctly: + """accessMode must not describe gateway inventory as usable. + + The mapping was `"owned" if mode == "paid" else "shared"`. Pool inventory + registers with mode="pool", which is not "paid", so it fell through to + "shared" — the value that tells a client the batch is free for anyone to use, + about exactly the batches check_access refuses. A client would believe the + listing and be denied on upload. + """ + + def test_pool_owned_batch_is_not_reported_as_shared(self): + from app.services.stamp_ownership import POOL_OWNER + + for owner, mode, expected in ( + (POOL_OWNER, "pool", "pool"), + ("0xWallet", "paid", "owned"), + ("shared", "free", "shared"), + ): + info = {"owner": owner, "mode": mode} + if info["owner"] == POOL_OWNER: + access_mode = "pool" + elif info.get("mode") == "paid": + access_mode = "owned" + else: + access_mode = "shared" + assert access_mode == expected, f"{owner}/{mode} mapped to {access_mode}" + + def test_model_accepts_pool_access_mode(self): + """The field is a Literal; an unlisted value is rejected at serialisation.""" + from app.api.models.stamp import StampDetails + import typing + allowed = typing.get_args(typing.get_args(StampDetails.model_fields["accessMode"].annotation)[0]) + assert "pool" in allowed, f"accessMode Literal does not permit 'pool': {allowed}" diff --git a/tests/test_stamp_pool.py b/tests/test_stamp_pool.py index c768e62..b60fd2b 100644 --- a/tests/test_stamp_pool.py +++ b/tests/test_stamp_pool.py @@ -1148,3 +1148,85 @@ def test_returns_none_without_a_response(self): request = httpx.Request("POST", "http://bee:1633/stamps/1/20") assert _bee_error_message(httpx.ConnectError("refused", request=request)) is None + + +class TestPoolInventoryIsRegisteredAsOwned: + """The pool must claim ownership of what it buys. + + check_access refuses batches owned by POOL_OWNER, and refuses untracked ones. + Both of those are enforcement — they hold only if the pool actually registers + its inventory. Without these tests the enforcement suite passes whether or not + the registration call exists, which is precisely how #312 stayed open: the + lock was fine, nothing locked the door. + """ + + @pytest.fixture + def state_file(self, tmp_path): + return str(tmp_path / "pool_state.json") + + def test_purchased_stamp_is_registered_to_the_pool(self, state_file): + """A batch is owned from the moment it exists, not from when it is handed out.""" + from app.services.stamp_ownership import POOL_OWNER + + manager = StampPoolManager(state_file=state_file) + registered = [] + + with patch('app.services.stamp_ownership.stamp_ownership_manager.register_stamp', + side_effect=lambda **kw: registered.append(kw)): + manager.add_stamp_to_pool("batch_new", 17, 1000000, 604800) + + assert len(registered) == 1, "the pool bought a batch and claimed no ownership of it" + assert registered[0]["batch_id"] == "batch_new" + assert registered[0]["owner"] == POOL_OWNER + assert registered[0]["source"] == "pool_purchase" + + def test_purchase_survives_a_registration_failure(self, state_file): + """Bookkeeping must not lose a batch that was already paid for. + + The batch exists on chain regardless. Raising here would drop it from the + pool while the money is spent, which is worse than an unprotected batch — + so it is logged instead, and the log says it is unprotected. + """ + manager = StampPoolManager(state_file=state_file) + + with patch('app.services.stamp_ownership.stamp_ownership_manager.register_stamp', + side_effect=RuntimeError("registry unavailable")): + stamp = manager.add_stamp_to_pool("batch_paid", 20, 1000000, 604800) + + assert stamp is not None + assert "batch_paid" in manager._pool, "a paid-for batch was lost when registration failed" + + @pytest.mark.asyncio + async def test_sync_adopts_pre_existing_inventory(self, state_file): + """Batches bought before this change must not stay open for their whole life. + + Registering at purchase only protects what is bought from now on. Anything + already held predates it and would remain untracked — the exact state that + let a production batch reach 50% utilisation unasked. + """ + from app.services.stamp_ownership import POOL_OWNER + import json + + with open(state_file, "w") as f: + json.dump(["old_batch_1", "old_batch_2"], f) + + manager = StampPoolManager(state_file=state_file) + bee_stamps = [ + {"batchID": "old_batch_1", "depth": 17, "local": True, "usable": True, + "batchTTL": 604800, "amount": "1000000", "label": ""}, + {"batchID": "old_batch_2", "depth": 20, "local": True, "usable": True, + "batchTTL": 604800, "amount": "1000000", "label": ""}, + ] + + registered = [] + with patch('app.services.stamp_pool.swarm_api.get_all_stamps_processed', return_value=bee_stamps): + with patch('app.services.stamp_ownership.stamp_ownership_manager.register_stamp', + side_effect=lambda **kw: registered.append(kw)): + synced = await manager.sync_from_bee_node() + + assert synced == 2 + adopted = {r["batch_id"] for r in registered if r["owner"] == POOL_OWNER} + assert adopted == {"old_batch_1", "old_batch_2"}, ( + f"sync left pre-existing inventory unprotected: adopted {adopted}" + ) + assert all(r["source"] == "pool_sync" for r in registered)