Repository navigation
fix: PeerPayClient rejects BRC-29's documented base64 transaction, and no size check precedes broadcast #548
Description
Area
Messaging
Affected package or service and version
packages/messaging/message-box-client (PeerPayClient), packages/sdk (BRC100ByteEncoding), and infra/message-box-server. All references read from master at bdaebe6 on 2026-09-18. Reporting from an independent BRC-29/BRC-33 implementation (a Rust wallet in a desktop browser), not from a ts-stack deployment.
Defect
PeerPayClient accepts a BRC-29 payment token whose transaction is a byte array and refuses one that is base64, which is the encoding BRC-29's own text shows. Separately, nothing on the send path checks the assembled message against the relay's body limit before createAction broadcasts, so a payment can go on chain and then be permanently undeliverable.
Not a regression. Both halves are the current, deliberate contract; the second half has no owner in the code today.
Minimal reproduction
# 1. Encoding — a spec-shaped token is refused.
# BRC-29 documents: "transaction": "<base64 AtomicBEEF>"
# Send a PeerPay payment whose token.transaction is that base64 string.
# packages/messaging/message-box-client/src/PeerPayClient.ts:422
#
# const transaction = toBRC100PortableByteArray(payment.token.transaction)
# if (transaction == null || transaction.length === 0) {
# throw new Error('Payment transaction must be a non-empty BRC-100 byte array')
# }
#
# packages/sdk/src/wallet/BRC100ByteEncoding.ts:81 :: normalizeBRC100ByteArray
# accepts Uint8Array | number[] | contiguous numeric-key object. There is no
# base64 branch, so the spec-shaped value returns undefined and the send throws.
# 2. Size — a payment is broadcast, then cannot be delivered.
# Fund a PeerPay send from a UTXO with one large mined parent, so the
# Atomic BEEF (BRC-62: every direct parent in full) exceeds ~220 KB.
# With the array encoding that crosses a 1 MiB standard-profile body:
# infra/message-box-server/src/routes/sendMessage.ts:162 -> 413
# ERR_MESSAGE_BODY_TOO_LARGE. createAction has already broadcast by then.Expected behavior
Encoding. BRC-29 shows the payment token as "transaction": "<base64 AtomicBEEF>". A sender following the specification should be able to deliver to a wallet built on this client.
Size. A payment that cannot be delivered should fail before it is broadcast, not after. BRC-33 states no maximum message size, and the relay's limit is not discoverable from any endpoint, so the sender has nothing to size against.
Actual behavior and evidence
1. The receive path takes byte arrays only.
Because the spec-shaped value is rejected, every interoperable sender — the BSV Association's and ours — sends the numeric array instead. That is about 3.6× the size of the bytes, and after BRC-2 encryption and base64 roughly 4.7×.
The rejection is an improvement on what this code used to do: the earlier new Uint8Array(payment.token.transaction) turned a base64 string into character codes silently. The contract is now explicit. It is also, now, explicitly the opposite of what BRC-29 documents.
2. A payment can be broadcast and then be undeliverable.
sendMessage.ts:162 rejects a body over resourceConfig.maxMessageBodyBytes with 413 ERR_MESSAGE_BODY_TOO_LARGE. That limit is per deployment profile — src/config/resources.ts:62-66: 256 KiB (small), 1 MiB (standard), 4 MiB (highThroughput), overridable by MAX_MESSAGE_BODY_BYTES.
With the array encoding, a 1 MiB standard profile means an Atomic BEEF over roughly 220 KB cannot be delivered. BRC-62 requires every direct parent in full, so a single large parent — a data transaction, a big consolidation — is enough to cross that line.
Measured on mainnet, 2026-09-15, send 3798109e5612bc7e8bc1ebffc02d5c60a0b6e0d96ac84bf916542a3157d4ab55:
| Atomic BEEF | 494,725 bytes — the payment plus 11 mined parents, one of them 433,257 bytes |
| Payment token as JSON with the array encoding | 1,764,588 bytes |
Same token with base64 transaction |
≈ 660 KB, ≈ 880 KB on the wire — would have delivered |
| Relay response | 413 ERR_MESSAGE_BODY_TOO_LARGE, 20 attempts, then exhausted |
| Outcome | Coins on chain, recipient never notified, recipient cannot derive the output key |
3. The limit is not discoverable before you hit it.
The only place the number appears is in the 413 response text. /health returns ok, status, service, network and websockets, and advertises no limits; message-box-client fetches no configuration. So a sender cannot learn the cap except by exceeding it — and because it is a deployment profile, the answer differs 16× between two hosts running this same software.
Environment
Mainnet. Relay: public MessageBox host, standard profile (1 MiB), inferred from its 413 text on 2026-09-15. Sender: independent Rust BRC-29/BRC-33 implementation in a desktop browser, Windows. Code references read from bsv-blockchain/ts-stack master at bdaebe6, 2026-09-18.
Suggested changes, in this order
- Receive path: accept both encodings. Add a base64 branch to
normalizeBRC100ByteArray, or decode in the PeerPay receive path before normalising. Backward compatible, and it is the only order in which senders can move without breaking payments to existing wallets. - Expose the limit. Add
maxMessageBodyBytesto/health(or a small/limitsroute) and have the client read it. Cheap, since the value is already inresourceConfig. - Send path: build the message and check its size before
createActionbroadcasts. Fail the payment with a clear error rather than broadcasting and then discovering it cannot be delivered. Today the order is broadcast → attempt delivery → retry, which strands the recipient. - Send path, after (1) has shipped widely: emit base64 per BRC-29.
- Treat a
413as permanent for that payload rather than retrying it 20 times — the payload will never fit — and surface it to the user.
We are happy to turn 2–5 into wording, and to write the patch for (1) if you want it. We held off opening a PR because the ordering (receivers ship before senders switch) and the contract of normalizeBRC100ByteArray — an SDK-wide helper rather than a PeerPay detail — are yours to decide.
Related
- fix: normalize BRC-100 bytes across wallet transports #482 (
fix: normalize BRC-100 bytes across wallet transports, merged 2026-08-15) is what introducednormalizeBRC100ByteArrayand applied it across BRC-29 and Message Box/PeerPay. Point 1 above is a request to widen that helper at a boundary it already owns, not to undo it. - message-box-client: payment-bearing paths acknowledge (delete) the relay message before the payment is durably stored #503 is open in the same area — it concerns acknowledging a relay message before the payment is durably stored. Different defect; noting it so this is not read as a duplicate.
- Spec-side companions will follow on
bsv-blockchain/BRCsfor BRC-29/BRC-33 and for the same root cause on an HTTP header channel (BRC-121). I will link them here once they exist.
Safety checks
- I searched for an existing issue or pull request describing this defect.
- This report contains no secret, private key, token, production data, or uncoordinated vulnerability detail.
Activity
BSVArchie commentedon Sep 18, 2026
Spec-side companions are now filed, as promised above:
Nothing in this issue changes; the spec discussions are where the "advertise the limit" recommendation belongs.
ty-everett commentedon Sep 23, 2026
I plan to prioritize this as the next bounded payment-boundary repair after the current correctness/release wave. Perhaps one approach would be to first make the receiver accept the documented base64 representation alongside deployed byte arrays, with malformed/oversized input rejected before wallet mutation and shared TS/Rust vectors. We'll want to separately specify discoverable payload limits and preflight before broadcast, plus terminal handling for a permanent 413. Retain the linked BRCs repo # 260/261 discussions for the protocol decisions. We will not silently impose a new wire representation or advertise a limit the relay does not enforce.
ty-everett commentedon Sep 23, 2026
Backwards compatibility is important here. If the spec was ambiguous in the past, I think the reference implementation, as it exists in widespread existing deployments, should probably take precedent. We will however take the steps needed to document, reduce ambiguity, ensure compatibility, and importantly: preserve existing implementations including backwards-compatibility, even where the initial version of the spec or documentation may have been ambiguous.
I apologize for this confusion, these are really early days with these specs, but your independent implementation is literally the reason why we wrote them! So we'll work towards the best solution.