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
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.
The existing sequential replay test still passes.
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
checkChannelHashcashininternal/service/service.govalidates the stamp, then callsIsHashcashSpent(a SELECT) and, if it is not spent,RecordSpentHashcash(anINSERT OR IGNORE), both ininternal/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 (checkAndRecordStampininternal/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 concurrentPOST /api/v1/messageswith 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.
RecordSpentHashcashruns theINSERT OR IGNOREand reports whether a row was inserted (RowsAffected), andcheckChannelHashcashrefuses the message with 404ERR_CANNOTSENDTOCHANwhen it was not;IsHashcashSpentthen has no caller on this path. One statement is atomic on SQLite, with any number of connections.Definition of done
internal/serviceorinternal/handlerssends the same stamp from many goroutines under-raceand checks the count of accepted messages and of delivered copies is one.next.Model: fable-5-1