Skip to content

Add a Buzz client, with NIP-29 chat messages and NIP-98 HTTP auth - #107

Open
Synesso wants to merge 3 commits into
mainfrom
jmawson/bcoin-10549-port-buzz-client-to-nostrino
Open

Synesso wants to merge 3 commits into
mainfrom
jmawson/bcoin-10549-port-buzz-client-to-nostrino

Conversation

@Synesso

@Synesso Synesso commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

ELI5: Adds a small client that lets any Kotlin service post a chat message to a Buzz channel, so services can reuse one implementation instead of each writing their own.

Adds a dedicated app.cash.nostrino.buzz package, plus the NIP-29 and NIP-98 event types it needs.

  • BuzzClient.post sends a kind-9 GroupChatMessage to the community's /events endpoint with a NIP-98 HttpAuth header, and succeeds only when the relay acknowledges that exact event id.
  • New public API: EventContent.sign(sec, at) signs at a given instant, so a retry with the same inputs re-sends the same event id (the relay stores it once) instead of posting a duplicate. sign(sec) keeps its behaviour.
  • Adds mockwebserver3 as a test-only dependency for the client tests.

Ports btc-mana-bar's Buzz client and config (BCOIN-10549) into a
dedicated buzz package, so other services can post to a Buzz channel
without copying it.

- BuzzChannel.parse validates a community's https:// or wss:// origin
  and a lowercase channel UUID.
- BuzzClient posts a kind-9 GroupChatMessage tagged with the channel to
  the community's /events endpoint, authorized by a NIP-98 HttpAuth
  event, and succeeds only when the relay acknowledges that event id.
- EventContent.sign(sec, at) signs at a given instant, so a retry
  re-sends the same event.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a1ad78-8f9d-75ce-954c-6877855b49f2
@Synesso

Synesso commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 @builderbot review

@Synesso
Synesso marked this pull request as ready for review November 6, 2026 07:12
@Synesso
Synesso requested review from a team, jmateoac and tyiu as code owners November 6, 2026 07:12

@rgrnwd rgrnwd left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The client needs fixes for copied authorization fields and malformed acknowledgement handling.

val payload: ByteString? = null,
val nonce: String? = null,
override val tags: List<Tag> = listOfNotNull(
UrlTag(url),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated data-class copy preserves existing tags, so copy(url = anotherUrl) or copy(payload = anotherHash) still signs the original URL or payload and produces authorization the new request cannot use. Derive required tags from the current fields, and add coverage for copying authorization fields; GroupChatMessage has the same problem with copy(groupId = ...).

.build()
val acknowledgement = httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Buzz relay returned HTTP ${response.code}.")
acknowledgementAdapter.fromJson(response.body.source())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A successful HTTP response containing an unexpected JSON shape, such as [], makes Moshi throw JsonDataException instead of the documented IOException, bypassing callers' IOException handling for failed posts. Wrap decoding failures in IOException and test malformed acknowledgements.

/** Accepts every event, acknowledging it by its own id as a Buzz relay does. */
val acknowledging = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val id = Event.fromJson(request.body.clone().readUtf8())!!.id.hex()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: !! -> .shouldNotBeNull()

@jacksoncook jacksoncook left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new nonce tag handling loses data from NIP-13 proof-of-work tags.

"u" -> UrlTag(value)
"method" -> MethodTag(value)
"payload" -> PayloadTag(value.decodeHex())
"nonce" -> NonceTag(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing a NIP-13 tag such as ["nonce", "123", "20"] now produces a NonceTag that serializes as ["nonce", "123"], dropping the committed difficulty. Preserve the third field so typed-tag round trips do not remove proof-of-work information.

A successful response whose body is not a JSON object made Moshi throw
JsonDataException, escaping callers that handle IOException as documented.

Co-authored-by: Janice <6752921105b56ab2273ad065a495f94c1b44ee82067c394bb7cccd0ecc41e033@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jeremy Mawson <jmawson@squareup.com>

@jacksoncook jacksoncook left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nonce tag parser still drops NIP-13 proof-of-work data.

"u" -> UrlTag(value)
"method" -> MethodTag(value)
"payload" -> PayloadTag(value.decodeHex())
"nonce" -> NonceTag(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing ["nonce", "123", "20"] produces a NonceTag that serializes as ["nonce", "123"], losing the committed difficulty during typed-tag round trips. Preserve the third field so proof-of-work information survives.

@rgrnwd rgrnwd left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Malformed acknowledgement handling is fixed, but copying event fields can still leave stale signed tags.

val payload: ByteString? = null,
val nonce: String? = null,
override val tags: List<Tag> = listOfNotNull(
UrlTag(url),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated copy function preserves existing tags, so copy(url = anotherUrl) or copy(payload = anotherHash) still signs the original values, causing authorization to fail for the new request; GroupChatMessage.copy(groupId = ...) likewise targets the original group. Derive required tags from the current fields and add tests covering these copies.

.build()
val acknowledgement = httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Buzz relay returned HTTP ${response.code}.")
try {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can this be refactored to using result {}?

Co-authored-by: Janice <6752921105b56ab2273ad065a495f94c1b44ee82067c394bb7cccd0ecc41e033@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jeremy Mawson <jmawson@squareup.com>

@jacksoncook jacksoncook left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nonce parser still loses proof-of-work data during round trips.

"u" -> UrlTag(value)
"method" -> MethodTag(value)
"payload" -> PayloadTag(value.decodeHex())
"nonce" -> NonceTag(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing ["nonce", "123", "20"] creates a NonceTag that serializes as ["nonce", "123"], dropping the NIP-13 committed difficulty. Preserve the third field so typed-tag round trips retain proof-of-work information.

@rgrnwd rgrnwd left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying the new event models can still leave signed tags inconsistent with their fields.

val payload: ByteString? = null,
val nonce: String? = null,
override val tags: List<Tag> = listOfNotNull(
UrlTag(url),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated copy function preserves existing tags, so copy(url = anotherUrl) or copy(payload = anotherHash) signs the original values and fails authorization for the new request; GroupChatMessage.copy(groupId = ...) similarly targets the original group. Derive required tags from the current fields and add tests covering these copies.

@jacksoncook jacksoncook left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nonce parser still drops proof-of-work data during round trips.

"u" -> UrlTag(value)
"method" -> MethodTag(value)
"payload" -> PayloadTag(value.decodeHex())
"nonce" -> NonceTag(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing ["nonce", "123", "20"] creates a NonceTag that serializes as ["nonce", "123"], losing the NIP-13 committed difficulty. Preserve the third field so typed-tag round trips retain proof-of-work information.

@rgrnwd rgrnwd left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying the new event models can still leave signed tags inconsistent with their fields.

val method: String,
val payload: ByteString? = null,
val nonce: String? = null,
override val tags: List<Tag> = listOfNotNull(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated copy function preserves existing tags, so copy(url = anotherUrl) or copy(payload = anotherHash) signs the original values and fails authorization for the new request; GroupChatMessage.copy(groupId = ...) similarly targets the original group. Derive required tags from the current fields and add tests covering these copies.

@jacksoncook jacksoncook left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nonce parser still loses proof-of-work data during typed-tag round trips.

"u" -> UrlTag(value)
"method" -> MethodTag(value)
"payload" -> PayloadTag(value.decodeHex())
"nonce" -> NonceTag(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing ["nonce", "123", "20"] creates a NonceTag that serializes as ["nonce", "123"], dropping the NIP-13 committed difficulty. Preserve the third field so round trips retain proof-of-work information.

@rgrnwd rgrnwd left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying the new event models can leave signed tags inconsistent with their fields.

@jacksoncook jacksoncook left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nonce parser still drops proof-of-work data during typed-tag round trips.

"u" -> UrlTag(value)
"method" -> MethodTag(value)
"payload" -> PayloadTag(value.decodeHex())
"nonce" -> NonceTag(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing ["nonce", "123", "20"] creates a NonceTag that serializes as ["nonce", "123"], losing the NIP-13 committed difficulty. Preserve the third field so round trips retain proof-of-work information.

@rgrnwd rgrnwd left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying the new event models can still leave signed tags inconsistent with their fields.

val method: String,
val payload: ByteString? = null,
val nonce: String? = null,
override val tags: List<Tag> = listOfNotNull(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated copy functions retain existing tags, so HttpAuth.copy(url = ...) or copy(payload = ...) authorizes the original request, while GroupChatMessage.copy(groupId = ...) still targets the original group. Derive required tags from the current fields and add tests covering these copies.

@jacksoncook jacksoncook left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nonce parser still loses proof-of-work data during typed-tag round trips.

@rgrnwd rgrnwd left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying the new event models can still produce signed tags that disagree with their fields.

val method: String,
val payload: ByteString? = null,
val nonce: String? = null,
override val tags: List<Tag> = listOfNotNull(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated copy functions retain existing tags, so HttpAuth.copy(url = ...) or copy(payload = ...) still authorizes the original request, while GroupChatMessage.copy(groupId = ...) still targets the original group. Derive required tags from the current fields and add tests covering these copies.

@jacksoncook jacksoncook left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nonce parser still drops proof-of-work data during tag round trips.

@rgrnwd rgrnwd left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying the new event models can still produce signed tags that disagree with their fields.

@jacksoncook jacksoncook left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nonce parser still loses proof-of-work data during tag round trips.

"u" -> UrlTag(value)
"method" -> MethodTag(value)
"payload" -> PayloadTag(value.decodeHex())
"nonce" -> NonceTag(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing ["nonce", "123", "20"] creates a NonceTag that serializes as ["nonce", "123"], dropping the NIP-13 committed difficulty. Preserve the third field so round trips retain the information needed to validate proof of work.

@rgrnwd rgrnwd left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying the new event models can still produce signed tags that disagree with their fields.

@jacksoncook jacksoncook left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nonce parser still drops proof-of-work data during tag round trips.

"u" -> UrlTag(value)
"method" -> MethodTag(value)
"payload" -> PayloadTag(value.decodeHex())
"nonce" -> NonceTag(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing ["nonce", "123", "20"] creates a NonceTag that serializes as ["nonce", "123"], losing the NIP-13 committed difficulty needed to validate proof of work. Preserve the third field during round trips.

@rgrnwd rgrnwd left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying the new event models can still produce signed tags that disagree with their fields.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants