Skip to content

POST /soc silently discards an overwrite and returns 201 Created #5546

Description

@chrishobcroft

Bee version: 2.8.1-7cf53193 (API 8.1.0), single node, default config.

Summary

Uploading a single-owner chunk to an address that already holds a SOC returns
201 Created, but the stored chunk is unchanged. The new payload is discarded
with no error, no warning, and a success status code. A client has no way to
tell a write that landed from a write that was dropped.

This happens on a mutable batch (immutableFlag: false), which is the case
where a caller is most likely to expect the write to take effect.

Reproduction

import requests
from eth_hash.auto import keccak
from eth_keys import keys

BEE   = "http://localhost:1633"
BATCH = "<a usable batch with immutableFlag: false>"

pk    = keys.PrivateKey(bytes.fromhex("11" * 32))
owner = pk.public_key.to_canonical_address()
ident = keccak(b"overwrite-demo")                 # fixed id -> fixed address
addr  = keccak(ident + owner)

def bmt_root(payload):                            # BMT over 32-byte segments
    lvl = [payload[i:i+32].ljust(32, b"\0") for i in range(0, 4096, 32)]
    while len(lvl) > 1:
        lvl = [keccak(lvl[i] + lvl[i+1]) for i in range(0, len(lvl), 2)]
    return lvl[0]

def put(payload):
    cac    = len(payload).to_bytes(8, "little") + payload
    digest = keccak(ident + keccak(cac[:8] + bmt_root(cac[8:])))
    sig    = pk.sign_msg_hash(keccak(b"\x19Ethereum Signed Message:\n32" + digest))
    sig_b  = sig.r.to_bytes(32,"big") + sig.s.to_bytes(32,"big") + bytes([sig.v + 27])
    return requests.post(f"{BEE}/soc/{owner.hex()}/{ident.hex()}",
                         params={"sig": sig_b.hex()}, data=cac,
                         headers={"swarm-postage-batch-id": BATCH,
                                  "content-type": "application/octet-stream"})

for v in (b"VERSION-ONE", b"VERSION-TWO", b"VERSION-THREE"):
    st  = put(v).status_code
    got = requests.get(f"{BEE}/chunks/{addr.hex()}").content[105:105+len(v)]
    print(f"wrote {v.decode():<15} POST={st}  GET -> {got!r}")

Actual

wrote VERSION-ONE      POST=201  GET -> b'VERSION-ONE'
wrote VERSION-TWO      POST=201  GET -> b'VERSION-ONE'
wrote VERSION-THREE    POST=201  GET -> b'VERSION-ONE'

All three uploads are signed correctly by the owner and all three are accepted
with 201. Only the first is stored.

Expected

Either of these would be fine; the current behaviour is neither:

  1. Reject explicitly409 Conflict (or 403), with a body explaining that
    a SOC already exists at this address and replacement is not supported. The
    caller learns the write did not happen.
  2. Honour the overwrite — replace the stored chunk, since the signature
    proves owner intent. 200 OK rather than 201 Created.

The problem is not which of these Bee chooses. It is that 201 Created asserts
a write that did not occur.

Why it matters

Any application treating SOC as a keyed store — a fixed address per logical
slot, updated over time — will appear to work in testing (the first write
lands) and silently serve stale data forever after. The failure is invisible at
the point of the bug and only surfaces much later as data corruption.

Feeds sidestep this by deriving a fresh identifier per update, so the mainline
Bee use case never hits it. That is exactly why it survives unnoticed.

Open questions

  • Is the discard happening in the localstore dedup path, or in SOC validation?
  • Would a node in the target neighbourhood accept the replacement, making the
    outcome depend on whether the uploader happens to be near the address? If so
    the behaviour is not just unreported but nondeterministic.
  • Is "SOC is write-once per address" an intended invariant? If so it should be
    documented alongside the /soc endpoint, which currently does not say it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions