Summary
In @bsv/message-box-client, several payment-bearing message paths remove the message
from the MessageBox server before the embedded payment is durably internalized into
the wallet. Because the BRC-42 derivation data exists only inside that message, a crash
or a failed internalize between the two steps leaves the output on-chain and
unspendable — there is no re-fetch and no chain rescan that recovers it.
PeerPayClient.acceptPayment already does this in the safe order
(internalize → acknowledge), so within the same package the paths disagree about
whether the server is a durability backstop.
Verified by reading the published build of v2.4.2
(dist/src/MessageBoxClient.js, dist/src/PeerPayClient.js, dist/src/types.d.ts),
cross-checked against v2.4.1. Two independent reviewers reached the same conclusion on
the primary finding.
1. MessageBoxClient.acknowledgeNotification — acknowledge before internalize
dist/src/MessageBoxClient.js, acknowledgeNotification:
async acknowledgeNotification(message) {
await this.acknowledgeMessage({ messageIds: [message.messageId] }); // ← deletes first
...
const recipientOutputs = paymentData.outputs.filter(o => o.protocol === "wallet payment");
if (recipientOutputs.length < 1) { return false; } // ← already deleted
if ((await this.walletClient.internalizeAction({ ... })).accepted) { // ← stores second
return true;
} else { return false; } // ← already deleted
} catch (paymentError) {
error("[MB CLIENT ERROR] Failed to internalize recipient payment:", paymentError);
return false; // ← already deleted
}
The doc comment states the ordering as intended behaviour, so this reads as deliberate
rather than accidental:
* 1. Calls `acknowledgeMessage()` to remove the message from the server's queue.
* 2. Checks the message body for embedded payment data.
* 3. If a recipient payment exists, attempts to internalize it into the wallet.
Because the delete happens first, every subsequent failure mode becomes a silent
loss rather than a retryable error:
| Path |
Outcome |
| Crash / OOM between the two calls |
payment lost |
internalizeAction throws |
caught, logged, return false — payment lost |
internalizeAction returns accepted: false |
return false — payment lost |
outputs.filter(...) matches nothing |
return false — payment lost |
In all four the caller receives false, which is indistinguishable from "this message
carried no payment". A crash surfaces nothing at all.
Minor, same function: this internalizeAction call omits this.originator, unlike
internalizeRecipientPayment and the PeerPay settlement path. On an originator-sensitive
wallet that is an additional way to fail after the message is already gone.
Suggested fix: internalize first and acknowledge only on success, matching
acceptPayment. That converts silent loss into at-least-once delivery, which the
internalize path already tolerates — a replayed internalize collides on outpoint
uniqueness rather than double-crediting.
2. listMessages / listMessagesLite strip the payment envelope while suppressing internalize failure
internalizeRecipientPayment logs and swallows both the throw and the not-accepted case:
if ((await this.walletClient.internalizeAction(/* ... */)).accepted)
log("[MB CLIENT] Successfully internalized recipient payment");
else
warn("[MB CLIENT] Recipient payment internalization was not accepted");
} catch (paymentError) {
error("[MB CLIENT ERROR] Failed to internalize recipient payment:", paymentError);
}
and the returned body is then replaced with only the inner content, discarding payment:
p.message.body = p.messageContent ?? p.parsedBody;
So a caller doing the ordinary poll-then-acknowledge lifecycle gets a message object
from which the payment metadata has been removed, with no indication that
internalization failed, and then acknowledges it. listMessages itself does not
acknowledge, so this is not a loss on its own — the loss lands on the subsequent
acknowledge. It becomes deterministic with acceptPayments: false, where
internalization is skipped but the envelope is stripped anyway; listMessagesLite
always strips.
Suggested fix: surface the internalization outcome to the caller, and preserve the
payment envelope on the returned object when it was not successfully internalized.
3. PeerPayClient.rejectPayment ignores acceptPayment's failure and acknowledges before the refund
acceptPayment resolves a string on failure rather than throwing:
} catch (error$2) {
error(`[PP CLIENT] Error accepting payment: ${String(error$2)}`);
return "Unable to receive payment!";
}
rejectPayment discards that return value and proceeds to refund:
await this.acceptPayment(payment);
await this.sendPayment({ recipient: payment.sender, amount: payment.token.amount - 1e3 });
Two consequences:
- Acceptance failed: the received output was never internalized, but the refund is
sent anyway — from unrelated wallet funds — and the original message is then
acknowledged. The recipient has paid the sender out of pocket and the original output
is unrecorded.
- Acceptance succeeded:
acceptPayment acknowledges immediately, so a crash or a
sendPayment throw before the refund leaves the sender un-refunded with the queue
record needed to resume already deleted.
Rejection is a multi-step monetary operation that acknowledges in the middle, so it is
not resumable.
4. acceptPayment treats a non-accepted wallet result as success
The success branch is taken without checking accepted, so a wallet returning
{ accepted: false } rather than throwing produces a "Payment internalized successfully"
log, a deleted relay message, and a success-shaped return value.
5. basket insertion outputs are filtered out despite being contract-valid
Both internalizeRecipientPayment and acknowledgeNotification filter with:
paymentData.outputs.filter((output) => output.protocol === "wallet payment")
but the package's own Payment type in dist/src/types.d.ts declares both protocols
valid, with dedicated fields for the second:
protocol: 'wallet payment' | 'basket insertion';
insertionRemittance?: {
basket: BasketStringUnder300Bytes;
customInstructions?: string;
tags?: OutputTagStringUnder300Bytes[];
};
A server following the declared interface can therefore send an output that the client
silently discards. With a mixed output set the call internalizes the wallet-payment
subset, returns true, and abandons the rest. Note also that undefined === 'wallet payment' is false, so an output with no protocol field is dropped as well.
Impact
Findings 1 and 3 are the ones with unbounded value exposure. The amounts in the
notification path are typically delivery fees, so the per-message value is small, but
the loss is silent, permanent, and unbounded in aggregate.
Happy to open a PR for finding 1 if that is useful — it is a small reordering plus error
propagation, and the ordering already exists in acceptPayment to copy.
Summary
In
@bsv/message-box-client, several payment-bearing message paths remove the messagefrom the MessageBox server before the embedded payment is durably internalized into
the wallet. Because the BRC-42 derivation data exists only inside that message, a crash
or a failed internalize between the two steps leaves the output on-chain and
unspendable — there is no re-fetch and no chain rescan that recovers it.
PeerPayClient.acceptPaymentalready does this in the safe order(internalize → acknowledge), so within the same package the paths disagree about
whether the server is a durability backstop.
Verified by reading the published build of v2.4.2
(
dist/src/MessageBoxClient.js,dist/src/PeerPayClient.js,dist/src/types.d.ts),cross-checked against v2.4.1. Two independent reviewers reached the same conclusion on
the primary finding.
1.
MessageBoxClient.acknowledgeNotification— acknowledge before internalizedist/src/MessageBoxClient.js,acknowledgeNotification:The doc comment states the ordering as intended behaviour, so this reads as deliberate
rather than accidental:
Because the delete happens first, every subsequent failure mode becomes a silent
loss rather than a retryable error:
internalizeActionthrowsreturn false— payment lostinternalizeActionreturnsaccepted: falsereturn false— payment lostoutputs.filter(...)matches nothingreturn false— payment lostIn all four the caller receives
false, which is indistinguishable from "this messagecarried no payment". A crash surfaces nothing at all.
Minor, same function: this
internalizeActioncall omitsthis.originator, unlikeinternalizeRecipientPaymentand the PeerPay settlement path. On an originator-sensitivewallet that is an additional way to fail after the message is already gone.
Suggested fix: internalize first and acknowledge only on success, matching
acceptPayment. That converts silent loss into at-least-once delivery, which theinternalize path already tolerates — a replayed internalize collides on outpoint
uniqueness rather than double-crediting.
2.
listMessages/listMessagesLitestrip the payment envelope while suppressing internalize failureinternalizeRecipientPaymentlogs and swallows both the throw and the not-accepted case:and the returned body is then replaced with only the inner content, discarding
payment:So a caller doing the ordinary poll-then-acknowledge lifecycle gets a message object
from which the payment metadata has been removed, with no indication that
internalization failed, and then acknowledges it.
listMessagesitself does notacknowledge, so this is not a loss on its own — the loss lands on the subsequent
acknowledge. It becomes deterministic with
acceptPayments: false, whereinternalization is skipped but the envelope is stripped anyway;
listMessagesLitealways strips.
Suggested fix: surface the internalization outcome to the caller, and preserve the
payment envelope on the returned object when it was not successfully internalized.
3.
PeerPayClient.rejectPaymentignoresacceptPayment's failure and acknowledges before the refundacceptPaymentresolves a string on failure rather than throwing:rejectPaymentdiscards that return value and proceeds to refund:Two consequences:
sent anyway — from unrelated wallet funds — and the original message is then
acknowledged. The recipient has paid the sender out of pocket and the original output
is unrecorded.
acceptPaymentacknowledges immediately, so a crash or asendPaymentthrow before the refund leaves the sender un-refunded with the queuerecord needed to resume already deleted.
Rejection is a multi-step monetary operation that acknowledges in the middle, so it is
not resumable.
4.
acceptPaymenttreats a non-accepted wallet result as successThe success branch is taken without checking
accepted, so a wallet returning{ accepted: false }rather than throwing produces a "Payment internalized successfully"log, a deleted relay message, and a success-shaped return value.
5.
basket insertionoutputs are filtered out despite being contract-validBoth
internalizeRecipientPaymentandacknowledgeNotificationfilter with:but the package's own
Paymenttype indist/src/types.d.tsdeclares both protocolsvalid, with dedicated fields for the second:
A server following the declared interface can therefore send an output that the client
silently discards. With a mixed output set the call internalizes the wallet-payment
subset, returns
true, and abandons the rest. Note also thatundefined === 'wallet payment'is false, so an output with noprotocolfield is dropped as well.Impact
Findings 1 and 3 are the ones with unbounded value exposure. The amounts in the
notification path are typically delivery fees, so the per-message value is small, but
the loss is silent, permanent, and unbounded in aggregate.
Happy to open a PR for finding 1 if that is useful — it is a small reordering plus error
propagation, and the ordering already exists in
acceptPaymentto copy.