Implements per-channel hashcash proof-of-work requirement for PRIVMSG as an anti-spam mechanism. Channel operators set a difficulty level via MODE +H <bits>, and clients must compute a proof-of-work stamp bound to the channel name and message body before sending.
Changes
Database
Added hashcash_bits column to channels table (default 0 = no requirement)
Added spent_hashcash table with stamp_hash unique key and created_at for TTL pruning
New queries: GetChannelHashcashBits, SetChannelHashcashBits, RecordSpentHashcash, IsHashcashSpent, PruneSpentHashcash
Validates: version, difficulty, date freshness (48h), channel binding, body hash binding, proof-of-work
Handler Changes (internal/handlers/api.go)
validateChannelHashcash() + verifyChannelStamp() — checks hashcash on PRIVMSG to protected channels
extractHashcashFromMeta() — parses hashcash stamp from meta JSON
applyChannelMode() / setHashcashMode() / clearHashcashMode() — MODE +H/-H support
queryChannelMode() — shows +nH in mode query when hashcash is set
Meta field now passed through the full dispatch chain (dispatchCommand → handlePrivmsg → handleChannelMsg → sendChannelMsg → fanOut → InsertMessage)
ISUPPORT updated: CHANMODES=,H,,imnst (H in type B = parameter when set)
Replay Prevention
Spent stamps persisted to SQLite spent_hashcash table
1-year TTL (per issue requirements)
Automatic pruning in cleanup loop
Client Support (internal/cli/api/hashcash.go)
MintChannelHashcash(bits, channel, body) — computes stamps for channel messages
Tests
12 unit tests in internal/hashcash/channel_test.go: happy path, wrong channel, wrong body hash, insufficient bits, zero bits skip, bad format, bad version, expired stamp, missing body hash, body hash determinism, stamp hash, mint+validate round-trip
10 integration tests in internal/handlers/api_test.go: set mode, query mode, clear mode, reject no stamp, accept valid stamp, reject replayed stamp, no requirement works, invalid bits range, missing bits arg
README
Added +H to channel modes table
Added "Per-Channel Hashcash (Anti-Spam)" section with full documentation
Updated meta field description to mention hashcash
Client sends PRIVMSG with meta.hashcash field containing the stamp
Server validates stamp, checks spent cache, records as spent, relays message
Replayed stamps are rejected for 1 year
Docker Build
docker build . passes clean (formatting, linting, all tests).
closes #12
## Summary
Implements per-channel hashcash proof-of-work requirement for PRIVMSG as an anti-spam mechanism. Channel operators set a difficulty level via `MODE +H <bits>`, and clients must compute a proof-of-work stamp bound to the channel name and message body before sending.
## Changes
### Database
- Added `hashcash_bits` column to `channels` table (default 0 = no requirement)
- Added `spent_hashcash` table with `stamp_hash` unique key and `created_at` for TTL pruning
- New queries: `GetChannelHashcashBits`, `SetChannelHashcashBits`, `RecordSpentHashcash`, `IsHashcashSpent`, `PruneSpentHashcash`
### Hashcash Validation (`internal/hashcash/channel.go`)
- `ChannelValidator` type for per-channel stamp validation
- `BodyHash()` computes hex-encoded SHA-256 of message body
- `StampHash()` computes deterministic hash of stamp for spent-token key
- `MintChannelStamp()` generates valid stamps (for clients)
- Stamp format: `1:bits:YYMMDD:channel:bodyhash:counter`
- Validates: version, difficulty, date freshness (48h), channel binding, body hash binding, proof-of-work
### Handler Changes (`internal/handlers/api.go`)
- `validateChannelHashcash()` + `verifyChannelStamp()` — checks hashcash on PRIVMSG to protected channels
- `extractHashcashFromMeta()` — parses hashcash stamp from meta JSON
- `applyChannelMode()` / `setHashcashMode()` / `clearHashcashMode()` — MODE +H/-H support
- `queryChannelMode()` — shows +nH in mode query when hashcash is set
- Meta field now passed through the full dispatch chain (dispatchCommand → handlePrivmsg → handleChannelMsg → sendChannelMsg → fanOut → InsertMessage)
- ISUPPORT updated: `CHANMODES=,H,,imnst` (H in type B = parameter when set)
### Replay Prevention
- Spent stamps persisted to SQLite `spent_hashcash` table
- 1-year TTL (per issue requirements)
- Automatic pruning in cleanup loop
### Client Support (`internal/cli/api/hashcash.go`)
- `MintChannelHashcash(bits, channel, body)` — computes stamps for channel messages
### Tests
- **12 unit tests** in `internal/hashcash/channel_test.go`: happy path, wrong channel, wrong body hash, insufficient bits, zero bits skip, bad format, bad version, expired stamp, missing body hash, body hash determinism, stamp hash, mint+validate round-trip
- **10 integration tests** in `internal/handlers/api_test.go`: set mode, query mode, clear mode, reject no stamp, accept valid stamp, reject replayed stamp, no requirement works, invalid bits range, missing bits arg
### README
- Added `+H` to channel modes table
- Added "Per-Channel Hashcash (Anti-Spam)" section with full documentation
- Updated `meta` field description to mention hashcash
## How It Works
1. Channel operator sets requirement: `MODE #general +H 20` (20 bits)
2. Client mints stamp: computes SHA-256 hashcash bound to `#general` + SHA-256(body)
3. Client sends PRIVMSG with `meta.hashcash` field containing the stamp
4. Server validates stamp, checks spent cache, records as spent, relays message
5. Replayed stamps are rejected for 1 year
## Docker Build
`docker build .` passes clean (formatting, linting, all tests).
Add per-channel hashcash requirement via MODE +H <bits>. When set,
PRIVMSG to the channel must include a valid hashcash stamp in the
meta.hashcash field bound to the channel name and message body hash.
Server validates stamp format, difficulty, date freshness, channel
binding, body hash binding, and proof-of-work. Spent stamps are
persisted to SQLite with 1-year TTL for replay prevention.
Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter
Changes:
- Schema: add hashcash_bits column to channels, spent_hashcash table
- DB: queries for get/set channel hashcash bits, spent token CRUD
- Hashcash: ChannelValidator, BodyHash, StampHash, MintChannelStamp
- Handlers: validate hashcash on PRIVMSG, MODE +H/-H support
- Pass meta through fanOut chain to store in messages
- Prune spent hashcash tokens in cleanup loop (1-year TTL)
- Client: MintChannelHashcash helper for CLI
- Tests: 12 new channel_test.go + 10 new api_test.go integration tests
- README: document +H mode, stamp format, and usage
Standard hashcash spec format — 1:bits:YYMMDD:channel:bodyhash:counter (6 fields)
Policy Compliance
Schema changes in 001_initial.sql (pre-1.0) — not a new migration file
No new external deps — go.mod/go.sum unchanged
No code in cmd/
All exported types/functions have doc comments
Tests for all new exported types/functions (88.1% coverage on hashcash package)
README updated with +H mode documentation
Issues Found
BUG: CHANMODES ISUPPORT classification is wrong
File: internal/handlers/api.go line 303
Current: CHANMODES=,H,,imnst — H is in Type B (parameter required on both set and unset).
Should be: CHANMODES=,,H,imnst — H should be Type C (parameter required only when set).
Per IRC ISUPPORT spec, the 4 comma-separated groups are: A (list modes), B (always takes param on set AND unset, e.g. channel key +k), C (takes param only when set, e.g. user limit +l), D (never takes param).
+H 20 requires a bits parameter when setting, but -H does NOT require a parameter. This makes H a Type C mode. Clients parsing ISUPPORT will incorrectly expect a parameter for -H, causing protocol-level misparse of mode change notifications.
Fix: Change ,H,, to ,,H, in the ISUPPORT string.
Code duplication: MintChannelHashcash in CLI package
File: internal/cli/api/hashcash.go
MintChannelHashcash() duplicates logic from hashcash.BodyHash() + hashcash.MintChannelStamp(). The CLI package can import internal/hashcash/ since they are in the same module. Refactor to call the existing functions.
Verdict
FAIL — The CHANMODES Type B vs Type C misclassification is a real protocol interoperability bug. One-character fix: move H from the second to the third comma-separated position. The code duplication is a secondary concern that should also be addressed.
Otherwise this is a very solid PR — the core hashcash logic is correct, the 1-year TTL is properly implemented, replay prevention is DB-backed, test coverage is thorough, and the README documentation is comprehensive. Just needs these two fixes.
## Review: PR #79 — Per-channel hashcash requirement for PRIVMSG
### Build Result
docker build . **PASSES** — all tests pass (12 unit + 10 integration for hashcash, all existing tests green), linting clean, compilation clean.
### Requirements Checklist (vs Issue #12)
- [x] MODE +H sets hashcash bits — handleChannelMode handles +H bits, validates range 1-40
- [x] MODE -H clears requirement — correctly sets hashcash_bits = 0
- [x] PRIVMSG validates meta.hashcash — validateChannelHashcash + verifyChannelStamp
- [x] Stamp bound to channel name — resource field checked against channel name
- [x] Stamp bound to body hash (SHA-256) — BodyHash() computes SHA-256, validated in ValidateStamp
- [x] Stamp bound to date — validateTime() checks 48h max age, 1h future skew
- [x] Replay prevention (spent cache) — DB-backed spent_hashcash table with stamp_hash UNIQUE
- [x] **1-year TTL** per sneak's instruction — spentHashcashTTL = 365 * 24 * time.Hour
- [x] Standard hashcash spec format — 1:bits:YYMMDD:channel:bodyhash:counter (6 fields)
### Policy Compliance
- [x] Schema changes in 001_initial.sql (pre-1.0) — not a new migration file
- [x] No new external deps — go.mod/go.sum unchanged
- [x] No code in cmd/
- [x] All exported types/functions have doc comments
- [x] Tests for all new exported types/functions (88.1% coverage on hashcash package)
- [x] README updated with +H mode documentation
### Issues Found
#### BUG: CHANMODES ISUPPORT classification is wrong
**File:** internal/handlers/api.go line 303
Current: CHANMODES=,H,,imnst — H is in **Type B** (parameter required on both set and unset).
Should be: CHANMODES=,,H,imnst — H should be **Type C** (parameter required only when set).
Per IRC ISUPPORT spec, the 4 comma-separated groups are: A (list modes), B (always takes param on set AND unset, e.g. channel key +k), C (takes param only when set, e.g. user limit +l), D (never takes param).
+H 20 requires a bits parameter when setting, but -H does NOT require a parameter. This makes H a Type C mode. Clients parsing ISUPPORT will incorrectly expect a parameter for -H, causing protocol-level misparse of mode change notifications.
**Fix:** Change ,H,, to ,,H, in the ISUPPORT string.
#### Code duplication: MintChannelHashcash in CLI package
**File:** internal/cli/api/hashcash.go
MintChannelHashcash() duplicates logic from hashcash.BodyHash() + hashcash.MintChannelStamp(). The CLI package can import internal/hashcash/ since they are in the same module. Refactor to call the existing functions.
### Verdict
**FAIL** — The CHANMODES Type B vs Type C misclassification is a real protocol interoperability bug. One-character fix: move H from the second to the third comma-separated position. The code duplication is a secondary concern that should also be addressed.
Otherwise this is a very solid PR — the core hashcash logic is correct, the 1-year TTL is properly implemented, replay prevention is DB-backed, test coverage is thorough, and the README documentation is comprehensive. Just needs these two fixes.
- Move H from Type B to Type C in CHANMODES ISUPPORT string (H takes
a parameter only when set, not when unset)
- Refactor MintChannelHashcash to delegate to hashcash.BodyHash() and
hashcash.MintChannelStamp() instead of reimplementing them
H is now correctly classified as Type C (parameter required only when setting, not when unsetting)
This fixes IRC protocol interoperability — clients will no longer incorrectly expect a parameter for MODE -H
2. MintChannelHashcash deduplication (refactor)
File:internal/cli/api/hashcash.go
MintChannelHashcash() now delegates to hashcash.BodyHash() + hashcash.MintChannelStamp() from internal/hashcash/
Removed ~20 lines of reimplemented SHA-256 hashing and proof-of-work minting logic
Function signature and behavior unchanged
Build
docker build . passes clean — formatting, linting, all tests (12 unit + 10 integration for hashcash, all existing tests green), compilation.
## Rework Complete
Fixed both review findings:
### 1. CHANMODES ISUPPORT classification (bug fix)
**File:** `internal/handlers/api.go` line 303
- Changed `CHANMODES=,H,,imnst` → `CHANMODES=,,H,imnst`
- H is now correctly classified as Type C (parameter required only when setting, not when unsetting)
- This fixes IRC protocol interoperability — clients will no longer incorrectly expect a parameter for `MODE -H`
### 2. MintChannelHashcash deduplication (refactor)
**File:** `internal/cli/api/hashcash.go`
- `MintChannelHashcash()` now delegates to `hashcash.BodyHash()` + `hashcash.MintChannelStamp()` from `internal/hashcash/`
- Removed ~20 lines of reimplemented SHA-256 hashing and proof-of-work minting logic
- Function signature and behavior unchanged
### Build
`docker build .` passes clean — formatting, linting, all tests (12 unit + 10 integration for hashcash, all existing tests green), compilation.
docker build .PASSES — formatting, linting, all tests (12 unit + 10 integration for hashcash, all existing tests green), compilation clean.
Rework Verification
Fix 1: CHANMODES ISUPPORT classification ✅
File:internal/handlers/api.go line 303
Now reads CHANMODES=,,H,imnst — H is correctly in Type C (parameter required only when setting). The code concatenates "CHANMODES=,,H," + "imnst". Clients will correctly expect a parameter for +H but not for -H. Fix is correct.
Fix 2: CLI MintChannelHashcash deduplication ✅
File:internal/cli/api/hashcash.go
MintChannelHashcash() now delegates to hashcash.BodyHash(body) + hashcash.MintChannelStamp(bits, channel, bodyHash). No reimplemented SHA-256 or proof-of-work logic. Fix is correct.
PASS — Both rework findings are correctly addressed. All issue requirements are met. Code is clean, well-structured, and thoroughly tested. No issues found.
## Review: PR #79 — Per-channel hashcash (post-rework)
### Build Result
`docker build .` **PASSES** — formatting, linting, all tests (12 unit + 10 integration for hashcash, all existing tests green), compilation clean.
### Rework Verification
#### Fix 1: CHANMODES ISUPPORT classification ✅
**File:** `internal/handlers/api.go` line 303
Now reads `CHANMODES=,,H,imnst` — H is correctly in **Type C** (parameter required only when setting). The code concatenates `"CHANMODES=,,H," + "imnst"`. Clients will correctly expect a parameter for `+H` but not for `-H`. Fix is correct.
#### Fix 2: CLI MintChannelHashcash deduplication ✅
**File:** `internal/cli/api/hashcash.go`
`MintChannelHashcash()` now delegates to `hashcash.BodyHash(body)` + `hashcash.MintChannelStamp(bits, channel, bodyHash)`. No reimplemented SHA-256 or proof-of-work logic. Fix is correct.
### Requirements Checklist (vs [Issue #12](https://git.eeqj.de/sneak/chat/issues/12))
- [x] MODE +H sets hashcash bits — `setHashcashMode` validates range 1-40, persists to DB
- [x] MODE -H clears requirement — `clearHashcashMode` sets hashcash_bits = 0
- [x] PRIVMSG validates meta.hashcash — `validateChannelHashcash` + `verifyChannelStamp`
- [x] Stamp bound to channel name — resource field checked in `validateChannelHeader`
- [x] Stamp bound to body hash (SHA-256) — `BodyHash()` computes SHA-256, validated in `validateBodyHash`
- [x] Stamp bound to date — `parseStampDate` + `validateTime` (48h max age, 1h future skew)
- [x] **1-year TTL** per sneak's instruction — `spentHashcashTTL = 365 * 24 * time.Hour`
- [x] Replay prevention via DB — `spent_hashcash` table with `stamp_hash UNIQUE`, checked before relay
- [x] Standard hashcash format — `1:bits:YYMMDD:channel:bodyhash:counter` (6 fields)
### Policy Compliance
- [x] Schema changes in `001_initial.sql` (pre-1.0) — not a new migration file
- [x] No new external deps — `go.mod`/`go.sum` unchanged
- [x] No code changes in `cmd/`
- [x] All exported types/functions have doc comments
- [x] Tests for all new code (12 unit + 10 integration)
- [x] README updated with +H mode table entry and full "Per-Channel Hashcash" documentation section
- [x] All Docker base images pinned by sha256
- [x] Meta field properly threaded through entire dispatch chain (`HandleSendCommand` → `dispatchCommand` → `handlePrivmsg` → `handleChannelMsg`/`handleDirectMsg` → `sendChannelMsg`/`fanOut` → `InsertMessage`)
- [x] Channel hashcash reuses shared package functions (`validateProof`, `validateTime`, `parseStampDate`, `hasLeadingZeroBits`) — no duplication
### Verdict
**PASS** — Both rework findings are correctly addressed. All issue requirements are met. Code is clean, well-structured, and thoroughly tested. No issues found.
make test (i.e. go test ./...) compiles all packages including web/, which contains:
//go:embed dist/*varDistembed.FS
Inside Docker, the web-builder stage builds the SPA and copies web/dist/ into the build context before make test runs. But outside Docker (including bare make test in CI or locally), web/dist/ doesn't exist, so go:embed fails with pattern dist/*: no matching files found — breaking compilation of every package that transitively imports web.
The Dockerfile's lint stage already had a workaround (mkdir -p web/dist && touch web/dist/...), but the Makefile had no equivalent.
Fix
Added an ensure-web-dist target to the Makefile that creates placeholder files (index.html, style.css, app.js) when web/dist/ doesn't exist. Made build, test, and lint depend on it. This mirrors the existing pattern in the Dockerfile lint stage.
Additional: Rebase conflict resolution
Rebased onto current main (which gained #80 stats feature and #75 topic security fix since the branch was created). Resolved merge conflicts in internal/db/queries.go (new GetClientCount/GetQueueEntryCount from stats vs new hashcash query functions) and internal/handlers/handlers.go (new stats field vs new channelHashcash field — kept both).
Verification
✅make test passes (with web/dist/ absent — placeholder creation works)
✅ Squashed into single clean commit on top of main
## Rework: Fix `make test` failure outside Docker
### Root Cause
`make test` (i.e. `go test ./...`) compiles all packages including `web/`, which contains:
```go
//go:embed dist/*
var Dist embed.FS
```
Inside Docker, the `web-builder` stage builds the SPA and copies `web/dist/` into the build context before `make test` runs. But outside Docker (including bare `make test` in CI or locally), `web/dist/` doesn't exist, so `go:embed` fails with `pattern dist/*: no matching files found` — breaking compilation of every package that transitively imports `web`.
The Dockerfile's lint stage already had a workaround (`mkdir -p web/dist && touch web/dist/...`), but the Makefile had no equivalent.
### Fix
Added an `ensure-web-dist` target to the Makefile that creates placeholder files (`index.html`, `style.css`, `app.js`) when `web/dist/` doesn't exist. Made `build`, `test`, and `lint` depend on it. This mirrors the existing pattern in the Dockerfile lint stage.
### Additional: Rebase conflict resolution
Rebased onto current `main` (which gained [#80](https://git.eeqj.de/sneak/chat/pulls/80) stats feature and [#75](https://git.eeqj.de/sneak/chat/pulls/75) topic security fix since the branch was created). Resolved merge conflicts in `internal/db/queries.go` (new `GetClientCount`/`GetQueueEntryCount` from stats vs new hashcash query functions) and `internal/handlers/handlers.go` (new `stats` field vs new `channelHashcash` field — kept both).
### Verification
- ✅ `make test` passes (with `web/dist/` absent — placeholder creation works)
- ✅ `docker build .` passes (all tests, lint, fmt-check, compilation)
- ✅ Squashed into single clean commit on top of `main`
Root cause was correct:go:embed dist/* in web/embed.go fails when web/dist/ doesn't exist outside Docker.
Fix is correct:
ensure-web-dist target creates web/dist/{index.html,style.css,app.js} only when the directory is absent (if [ ! -d web/dist ])
build, test, and lint targets all depend on ensure-web-dist
Does NOT interfere with Docker build — the builder stage copies real files from web-builder before make test runs, and the lint stage already had its own equivalent mkdir -p && touch workaround
Comment above the target explains the rationale clearly
Rebase Verification
Single squashed commit (68558ce) on top of current main
No conflict markers anywhere in the codebase
#80 (stats): GetClientCount/GetQueueEntryCount in queries.go and stats field in handlers.go — both present and integrated correctly
#75 (topic security): handleTopic/executeTopic — present and working
PASS — The ensure-web-dist fix correctly resolves the make test failure outside Docker without interfering with the Docker build. Rebase onto main resolved cleanly with no conflicts. All original hashcash requirements remain satisfied. Both make test and docker build . pass clean. No issues found.
## Review: PR #79 — Per-channel hashcash (post-rework #2)
### Build Results
- ✅ `make test` **PASSES** — with `web/dist/` absent (placeholder creation works). All tests green: 12 unit (hashcash package, 88.1% coverage), 10 integration (handlers, 59.4% coverage), plus all existing tests.
- ✅ `docker build .` **PASSES** — formatting, linting, all tests, compilation, binary build all clean.
### Rework Verification: `ensure-web-dist` Makefile target
**Root cause was correct:** `go:embed dist/*` in `web/embed.go` fails when `web/dist/` doesn't exist outside Docker.
**Fix is correct:**
- `ensure-web-dist` target creates `web/dist/{index.html,style.css,app.js}` only when the directory is absent (`if [ ! -d web/dist ]`)
- `build`, `test`, and `lint` targets all depend on `ensure-web-dist`
- Does NOT interfere with Docker build — the builder stage copies real files from `web-builder` before `make test` runs, and the lint stage already had its own equivalent `mkdir -p && touch` workaround
- Comment above the target explains the rationale clearly
### Rebase Verification
- Single squashed commit (`68558ce`) on top of current `main`
- No conflict markers anywhere in the codebase
- [#80](https://git.eeqj.de/sneak/chat/pulls/80) (stats): `GetClientCount`/`GetQueueEntryCount` in `queries.go` and `stats` field in `handlers.go` — both present and integrated correctly
- [#75](https://git.eeqj.de/sneak/chat/pulls/75) (topic security): `handleTopic`/`executeTopic` — present and working
- `go.mod`/`go.sum` unchanged — no new dependencies
### Requirements Checklist (vs [Issue #12](https://git.eeqj.de/sneak/chat/issues/12))
- [x] MODE +H sets hashcash bits — `setHashcashMode` validates range 1-40, persists to DB
- [x] MODE -H clears requirement — `clearHashcashMode` sets hashcash_bits = 0
- [x] PRIVMSG validates meta.hashcash — `validateChannelHashcash` + `verifyChannelStamp`
- [x] Stamp bound to channel name — resource field checked in `validateChannelHeader`
- [x] Stamp bound to body hash (SHA-256) — `BodyHash()` computes SHA-256, validated in `validateBodyHash`
- [x] Stamp bound to date — `parseStampDate` + `validateTime` (48h max age, 1h future skew)
- [x] **1-year TTL** per sneak's instruction — `spentHashcashTTL = 365 * 24 * time.Hour`
- [x] Replay prevention via DB — `spent_hashcash` table with `stamp_hash UNIQUE`, checked before relay, 1-year pruning in cleanup loop
- [x] Standard hashcash format — `1:bits:YYMMDD:channel:bodyhash:counter` (6 fields)
- [x] CHANMODES ISUPPORT — `CHANMODES=,,H,imnst` (H correctly in Type C)
### Policy Compliance
- [x] Schema changes in `001_initial.sql` (pre-1.0) — not a new migration file
- [x] No new external deps — `go.mod`/`go.sum` unchanged
- [x] `.golangci.yml` NOT modified
- [x] No code changes in `cmd/`
- [x] All exported types/functions have doc comments
- [x] Tests for all new code (12 unit + 10 integration)
- [x] README updated with +H mode table entry and full "Per-Channel Hashcash" documentation section
- [x] All Docker base images pinned by sha256
- [x] Meta field properly threaded through entire dispatch chain
- [x] CLI `MintChannelHashcash` delegates to `hashcash.BodyHash()` + `hashcash.MintChannelStamp()` — no duplication
- [x] Channel hashcash reuses shared package functions (`validateProof`, `validateTime`, `parseStampDate`, `hasLeadingZeroBits`)
### Verdict
**PASS** — The `ensure-web-dist` fix correctly resolves the `make test` failure outside Docker without interfering with the Docker build. Rebase onto main resolved cleanly with no conflicts. All original hashcash requirements remain satisfied. Both `make test` and `docker build .` pass clean. No issues found.
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.
closes #12
Summary
Implements per-channel hashcash proof-of-work requirement for PRIVMSG as an anti-spam mechanism. Channel operators set a difficulty level via
MODE +H <bits>, and clients must compute a proof-of-work stamp bound to the channel name and message body before sending.Changes
Database
hashcash_bitscolumn tochannelstable (default 0 = no requirement)spent_hashcashtable withstamp_hashunique key andcreated_atfor TTL pruningGetChannelHashcashBits,SetChannelHashcashBits,RecordSpentHashcash,IsHashcashSpent,PruneSpentHashcashHashcash Validation (
internal/hashcash/channel.go)ChannelValidatortype for per-channel stamp validationBodyHash()computes hex-encoded SHA-256 of message bodyStampHash()computes deterministic hash of stamp for spent-token keyMintChannelStamp()generates valid stamps (for clients)1:bits:YYMMDD:channel:bodyhash:counterHandler Changes (
internal/handlers/api.go)validateChannelHashcash()+verifyChannelStamp()— checks hashcash on PRIVMSG to protected channelsextractHashcashFromMeta()— parses hashcash stamp from meta JSONapplyChannelMode()/setHashcashMode()/clearHashcashMode()— MODE +H/-H supportqueryChannelMode()— shows +nH in mode query when hashcash is setCHANMODES=,H,,imnst(H in type B = parameter when set)Replay Prevention
spent_hashcashtableClient Support (
internal/cli/api/hashcash.go)MintChannelHashcash(bits, channel, body)— computes stamps for channel messagesTests
internal/hashcash/channel_test.go: happy path, wrong channel, wrong body hash, insufficient bits, zero bits skip, bad format, bad version, expired stamp, missing body hash, body hash determinism, stamp hash, mint+validate round-tripinternal/handlers/api_test.go: set mode, query mode, clear mode, reject no stamp, accept valid stamp, reject replayed stamp, no requirement works, invalid bits range, missing bits argREADME
+Hto channel modes tablemetafield description to mention hashcashHow It Works
MODE #general +H 20(20 bits)#general+ SHA-256(body)meta.hashcashfield containing the stampDocker Build
docker build .passes clean (formatting, linting, all tests).Review: PR #79 — Per-channel hashcash requirement for PRIVMSG
Build Result
docker build . PASSES — all tests pass (12 unit + 10 integration for hashcash, all existing tests green), linting clean, compilation clean.
Requirements Checklist (vs Issue #12)
Policy Compliance
Issues Found
BUG: CHANMODES ISUPPORT classification is wrong
File: internal/handlers/api.go line 303
Current: CHANMODES=,H,,imnst — H is in Type B (parameter required on both set and unset).
Should be: CHANMODES=,,H,imnst — H should be Type C (parameter required only when set).
Per IRC ISUPPORT spec, the 4 comma-separated groups are: A (list modes), B (always takes param on set AND unset, e.g. channel key +k), C (takes param only when set, e.g. user limit +l), D (never takes param).
+H 20 requires a bits parameter when setting, but -H does NOT require a parameter. This makes H a Type C mode. Clients parsing ISUPPORT will incorrectly expect a parameter for -H, causing protocol-level misparse of mode change notifications.
Fix: Change ,H,, to ,,H, in the ISUPPORT string.
Code duplication: MintChannelHashcash in CLI package
File: internal/cli/api/hashcash.go
MintChannelHashcash() duplicates logic from hashcash.BodyHash() + hashcash.MintChannelStamp(). The CLI package can import internal/hashcash/ since they are in the same module. Refactor to call the existing functions.
Verdict
FAIL — The CHANMODES Type B vs Type C misclassification is a real protocol interoperability bug. One-character fix: move H from the second to the third comma-separated position. The code duplication is a secondary concern that should also be addressed.
Otherwise this is a very solid PR — the core hashcash logic is correct, the 1-year TTL is properly implemented, replay prevention is DB-backed, test coverage is thorough, and the README documentation is comprehensive. Just needs these two fixes.
Rework Complete
Fixed both review findings:
1. CHANMODES ISUPPORT classification (bug fix)
File:
internal/handlers/api.goline 303CHANMODES=,H,,imnst→CHANMODES=,,H,imnstMODE -H2. MintChannelHashcash deduplication (refactor)
File:
internal/cli/api/hashcash.goMintChannelHashcash()now delegates tohashcash.BodyHash()+hashcash.MintChannelStamp()frominternal/hashcash/Build
docker build .passes clean — formatting, linting, all tests (12 unit + 10 integration for hashcash, all existing tests green), compilation.Review: PR #79 — Per-channel hashcash (post-rework)
Build Result
docker build .PASSES — formatting, linting, all tests (12 unit + 10 integration for hashcash, all existing tests green), compilation clean.Rework Verification
Fix 1: CHANMODES ISUPPORT classification ✅
File:
internal/handlers/api.goline 303Now reads
CHANMODES=,,H,imnst— H is correctly in Type C (parameter required only when setting). The code concatenates"CHANMODES=,,H," + "imnst". Clients will correctly expect a parameter for+Hbut not for-H. Fix is correct.Fix 2: CLI MintChannelHashcash deduplication ✅
File:
internal/cli/api/hashcash.goMintChannelHashcash()now delegates tohashcash.BodyHash(body)+hashcash.MintChannelStamp(bits, channel, bodyHash). No reimplemented SHA-256 or proof-of-work logic. Fix is correct.Requirements Checklist (vs Issue #12)
setHashcashModevalidates range 1-40, persists to DBclearHashcashModesets hashcash_bits = 0validateChannelHashcash+verifyChannelStampvalidateChannelHeaderBodyHash()computes SHA-256, validated invalidateBodyHashparseStampDate+validateTime(48h max age, 1h future skew)spentHashcashTTL = 365 * 24 * time.Hourspent_hashcashtable withstamp_hash UNIQUE, checked before relay1:bits:YYMMDD:channel:bodyhash:counter(6 fields)Policy Compliance
001_initial.sql(pre-1.0) — not a new migration filego.mod/go.sumunchangedcmd/HandleSendCommand→dispatchCommand→handlePrivmsg→handleChannelMsg/handleDirectMsg→sendChannelMsg/fanOut→InsertMessage)validateProof,validateTime,parseStampDate,hasLeadingZeroBits) — no duplicationVerdict
PASS — Both rework findings are correctly addressed. All issue requirements are met. Code is clean, well-structured, and thoroughly tested. No issues found.
make testfails in CI - did you try it locally? @clawbot80f17dc335to68558ce5b5Rework: Fix
make testfailure outside DockerRoot Cause
make test(i.e.go test ./...) compiles all packages includingweb/, which contains:Inside Docker, the
web-builderstage builds the SPA and copiesweb/dist/into the build context beforemake testruns. But outside Docker (including baremake testin CI or locally),web/dist/doesn't exist, sogo:embedfails withpattern dist/*: no matching files found— breaking compilation of every package that transitively importsweb.The Dockerfile's lint stage already had a workaround (
mkdir -p web/dist && touch web/dist/...), but the Makefile had no equivalent.Fix
Added an
ensure-web-disttarget to the Makefile that creates placeholder files (index.html,style.css,app.js) whenweb/dist/doesn't exist. Madebuild,test, andlintdepend on it. This mirrors the existing pattern in the Dockerfile lint stage.Additional: Rebase conflict resolution
Rebased onto current
main(which gained #80 stats feature and #75 topic security fix since the branch was created). Resolved merge conflicts ininternal/db/queries.go(newGetClientCount/GetQueueEntryCountfrom stats vs new hashcash query functions) andinternal/handlers/handlers.go(newstatsfield vs newchannelHashcashfield — kept both).Verification
make testpasses (withweb/dist/absent — placeholder creation works)docker build .passes (all tests, lint, fmt-check, compilation)mainReview: PR #79 — Per-channel hashcash (post-rework #2)
Build Results
make testPASSES — withweb/dist/absent (placeholder creation works). All tests green: 12 unit (hashcash package, 88.1% coverage), 10 integration (handlers, 59.4% coverage), plus all existing tests.docker build .PASSES — formatting, linting, all tests, compilation, binary build all clean.Rework Verification:
ensure-web-distMakefile targetRoot cause was correct:
go:embed dist/*inweb/embed.gofails whenweb/dist/doesn't exist outside Docker.Fix is correct:
ensure-web-disttarget createsweb/dist/{index.html,style.css,app.js}only when the directory is absent (if [ ! -d web/dist ])build,test, andlinttargets all depend onensure-web-distweb-builderbeforemake testruns, and the lint stage already had its own equivalentmkdir -p && touchworkaroundRebase Verification
68558ce) on top of currentmainGetClientCount/GetQueueEntryCountinqueries.goandstatsfield inhandlers.go— both present and integrated correctlyhandleTopic/executeTopic— present and workinggo.mod/go.sumunchanged — no new dependenciesRequirements Checklist (vs Issue #12)
setHashcashModevalidates range 1-40, persists to DBclearHashcashModesets hashcash_bits = 0validateChannelHashcash+verifyChannelStampvalidateChannelHeaderBodyHash()computes SHA-256, validated invalidateBodyHashparseStampDate+validateTime(48h max age, 1h future skew)spentHashcashTTL = 365 * 24 * time.Hourspent_hashcashtable withstamp_hash UNIQUE, checked before relay, 1-year pruning in cleanup loop1:bits:YYMMDD:channel:bodyhash:counter(6 fields)CHANMODES=,,H,imnst(H correctly in Type C)Policy Compliance
001_initial.sql(pre-1.0) — not a new migration filego.mod/go.sumunchanged.golangci.ymlNOT modifiedcmd/MintChannelHashcashdelegates tohashcash.BodyHash()+hashcash.MintChannelStamp()— no duplicationvalidateProof,validateTime,parseStampDate,hasLeadingZeroBits)Verdict
PASS — The
ensure-web-distfix correctly resolves themake testfailure outside Docker without interfering with the Docker build. Rebase onto main resolved cleanly with no conflicts. All original hashcash requirements remain satisfied. Bothmake testanddocker build .pass clean. No issues found.