A channel hashcash stamp can be spent more than once: the spent check and the record are two statements #150

Closed
opened 2026-10-20 17:04:23 +02:00 by clawbot · 0 comments
Collaborator

checkChannelHashcash in internal/service/service.go validates the stamp, then calls IsHashcashSpent (a SELECT) and, if it is not spent, RecordSpentHashcash (an INSERT OR IGNORE), both in internal/db/queries.go. The database handle has one connection, but it is released between statements, so two requests carrying the same stamp can both pass the SELECT before either INSERT runs. Each then sends its message. The session-creation validator (checkAndRecordStamp in internal/hashcash/hashcash.go) checks and records under one lock and does not have this problem.

Seen on eceb5f2: on a channel with +H 4, 60 concurrent POST /api/v1/messages with one valid stamp got up to 5 {"status":"sent"} in one round (1 to 5 across eight rounds), and the other member received that many copies. A sequential retry afterwards is refused. README.md (Per-Channel Hashcash) promises that a stamp "has not been previously used" and that spent stamps are kept to prevent replay; one minted stamp buys several messages.

Fix: make the record the check. RecordSpentHashcash runs the INSERT OR IGNORE and reports whether a row was inserted (RowsAffected), and checkChannelHashcash refuses the message with 404 ERR_CANNOTSENDTOCHAN when it was not; IsHashcashSpent then has no caller on this path. One statement is atomic on SQLite, with any number of connections.

Definition of done

  1. Of any number of concurrent messages carrying the same stamp, exactly one is accepted; a test in internal/service or internal/handlers sends the same stamp from many goroutines under -race and checks the count of accepted messages and of delivered copies is one.
  2. The existing sequential replay test still passes.
  3. One reviewed PR against next.

Model: fable-5-1

`checkChannelHashcash` in `internal/service/service.go` validates the stamp, then calls `IsHashcashSpent` (a SELECT) and, if it is not spent, `RecordSpentHashcash` (an `INSERT OR IGNORE`), both in `internal/db/queries.go`. The database handle has one connection, but it is released between statements, so two requests carrying the same stamp can both pass the SELECT before either INSERT runs. Each then sends its message. The session-creation validator (`checkAndRecordStamp` in `internal/hashcash/hashcash.go`) checks and records under one lock and does not have this problem. Seen on `eceb5f2`: on a channel with `+H 4`, 60 concurrent `POST /api/v1/messages` with one valid stamp got up to 5 `{"status":"sent"}` in one round (1 to 5 across eight rounds), and the other member received that many copies. A sequential retry afterwards is refused. `README.md` (Per-Channel Hashcash) promises that a stamp "has not been previously used" and that spent stamps are kept to prevent replay; one minted stamp buys several messages. Fix: make the record the check. `RecordSpentHashcash` runs the `INSERT OR IGNORE` and reports whether a row was inserted (`RowsAffected`), and `checkChannelHashcash` refuses the message with 404 `ERR_CANNOTSENDTOCHAN` when it was not; `IsHashcashSpent` then has no caller on this path. One statement is atomic on SQLite, with any number of connections. ## Definition of done 1. Of any number of concurrent messages carrying the same stamp, exactly one is accepted; a test in `internal/service` or `internal/handlers` sends the same stamp from many goroutines under `-race` and checks the count of accepted messages and of delivered copies is one. 2. The existing sequential replay test still passes. 3. One reviewed PR against `next`. Model: fable-5-1
clawbot self-assigned this 2026-10-20 17:04:23 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/neoirc#150