Skip to content

Add BRC-300: Sponsored Onboarding and Wallet Choice (supersedes #222) - #223

Closed
vincemedia wants to merge 1 commit into
bsv-blockchain:masterfrom
vincemedia:brc-300-implementation-fixes
Closed

Add BRC-300: Sponsored Onboarding and Wallet Choice (supersedes #222)#223
vincemedia wants to merge 1 commit into
bsv-blockchain:masterfrom
vincemedia:brc-300-implementation-fixes

Conversation

@vincemedia

Copy link
Copy Markdown
Contributor

Supersedes #222, which I am closing in favour of this. Same document, six defects fixed.

Why there is a second PR

After opening #222 I built the sponsor console that section 9.1 reserves to a companion document, as a Next.js application with the auction, commitment batch, predicate evaluation and slot composition implemented as pure modules under test. Writing an implementation against a specification is the cheapest way to find out where the specification is wrong, and it found six things. One of them is serious enough that I would not want #222 merged.

The critical one: publishing an opening published the key to the money

Section 4.5 derived the commitment salt as SHA-256(sponsorPrivKey || campaignRef || uint32LE(i)). Section 7.1 derived the voucher private key as SHA-256(sponsorPrivKey || campaignRef || uint32LE(i)). Identical inputs, identical output.

Section 4.5.6 requires the opening (bountySats, salt_i) to be published in the settled record for every winning campaign. Section 7.1.4 makes voucherKey_i a bearer credential controlling bountySats. So the specification required a house to publish the private key controlling the bounty.

It is exploitable without privileged access. outcome in section 4.7.1 includes offered and expired, so a winner whose newcomer never claimed still has its opening published after the delay of section 8.4, while section 7.1.5 leaves the voucher funded and unspent. Anyone reading the public record could sweep it.

The implementation reproduced this faithfully, which is how it surfaced. lib/brc300/commitment.ts ended up containing the line export const deriveVoucherKey = deriveSalt, and a passing test asserted the two agreed as though it were a feature.

The fix is a domain-separating tag on the salt only:

salt_i       = SHA-256( "wallet choice salt" || sponsorPrivKey || campaignRef || uint32LE(i) )   # 4.5
voucherKey_i = SHA-256( sponsorPrivKey || campaignRef || uint32LE(i) )                           # 7.1, BRC-227 s2

Section 7.1 keeps BRC-227 section 2's derivation exactly, so nothing about voucher compatibility changes. Commitment i and voucher i remain the same slot, which was the useful property. They no longer remain the same bytes, which was never intended. Section 4.5.2 now states that the tag is load-bearing and why, so nobody optimises it away.

Appendix A.2 prints both values at i = 0 side by side and names the old behaviour, and verify_vectors.py asserts the separation at two indices.

The other five

The canonical empty predicate was never defined. Section 4.3 requires "an empty predicate matches everyone and MUST be written explicitly" without saying what it looks like. The implementation chose {"all": []} and its own validator then rejected it while its evaluator returned true for it, so the one campaign shape the specification obliges a sponsor to write was unwritable. Section 5.2.2 now defines it: an all with no terms is satisfied by everybody, an any with no terms by nobody, and those are the only zero-term forms.

paceBlocks had no semantics. Section 4.3.6 made pacing normative without saying what was bounded per window or how the window moved, so the implementation carried a lastDrawHeight it could not use. It is now pro rata over the campaign window: within any sliding paceBlocks window a house may reduce either budget by at most ceil(total * paceBlocks / (endHeight - startHeight)) and draw at most the same fraction of the batch. Every term is already in the campaign object, so two houses serving one campaign compute the same ceiling and no new field is needed.

expiresHeight had no upper bound. A chooser discarded an expired offer but nothing stopped a house issuing one valid for ten thousand blocks, which turns a bid resolved in 250 ms into a standing quote the sponsor never agreed to make. Section 4.4.6 now caps it at the request height plus 144.

There was no house-side eligibility list. Section 6.5 gives the sponsor a numbered list of claim checks; the house's equivalent was scattered across 4.2.5, 4.3.5, 4.5.5, 7.1.6 and 7.4.2, and the implementation had to assemble one by hand. Section 4.3.8 now consolidates all nine conditions. It also closes a case none of the scattered rules covered: a house whose published floor exceeds a campaign's maxBidSats must treat that campaign as ineligible rather than enter it and charge it its cap.

An auction house could skim the publisher's share. Section 7.5 said the sponsor pays the site, without saying the payment goes direct, so a house sitting in the path could retain a percentage while violating nothing written. The implementation had a shareRoutingPct for exactly this, defaulting to zero and clearly uncomfortable about existing. Section 7.5.3 now requires payment directly from sponsor to site and forbids routing or retention, with 7.5.4 giving the reason: a house's compensation is maxBidSats, which is capped, published as a model and itemised per auction in a statement the sponsor can reconcile, and a share it merely passes along is subject to none of that.

Verification

92 checks to 98, still no third-party imports and no network.

cd apps/0300-vectors && python3 verify_vectors.py

The six new checks cover the separation directly: that salt_i differs from voucherKey_i at two indices, and that the printed voucherKey_0 really is BRC-227's untagged derivation rather than a decorative constant. Every value in Appendix A was regenerated, since tagging the salt changes every commitment and therefore the offer signature that carries one.

What did not change

The document's structure, arguments and normative surface are otherwise as in #222: the connect surface and its metanet.connect manifest entry, the two-vendor chooser, the first-price auction on the bounty, the published bid set with winners-only openings, client-evaluated predicates, and mandatory pre-funding. Section 9.1 still reserves the sponsor console to a companion document. It now has a working implementation behind it, which is what produced this diff.

Still open for review

Unchanged from #222 and none blocking: the five-term predicate vocabulary, the retentionBlocks default, whether pre-funding's capital requirement excludes small vendors, the absence of a rule tying batch size to budget, whether maxBidSats needs its own commitment, and whether a site may decline a specific campaign.

Also unchanged: no BRC has shipped code upstream before, and I remain happy to drop apps/0300-vectors/ if you would rather the repository stay markdown-only. This PR is the argument for keeping it. The critical defect above was invisible to every reviewer including me, survived five revisions of prose, and was found the moment something had to run.

🤖 Generated with Claude Code

Supersedes bsv-blockchain#222. Six defects were found by building the sponsor console
against the earlier revision; all six are fixed here.

The critical one: section 4.5 and section 7.1 derived salt_i and
voucherKey_i from the same three inputs, so the two were byte-identical.
Section 4.5.6 publishes the salt in the settled record and section 7.1.4
makes the voucher key a bearer credential controlling the bounty, so
publishing an opening published the private key to the money. Section 4.5
now tags its input; section 7.1 keeps BRC-227's untagged derivation. The
index space is still shared, the outputs no longer are.

Also fixed: the canonical empty predicate was never defined; paceBlocks
had no stated semantics; expiresHeight had no upper bound; there was no
house-side eligibility list to match section 6.5's sponsor-side one; and
nothing stopped an auction house routing and skimming the publisher's
retention share.

Vectors go from 92 checks to 98, including an assertion that a published
salt is not the voucher key at the same index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vincemedia

Copy link
Copy Markdown
Contributor Author

Superseded by #224.

@deggen raised in review that a malicious vendor could buy a prominent chooser slot, gather a userbase and rug it weeks later. He was right, and the defence in this revision turned out to be a single clause in the Security Considerations while section 4.4's offer carried the whole wallet object.

#224 makes catalog listing and a domain-attested sponsor key preconditions of bidding, strips card content out of the offer so a sponsor names a catalogued wallet rather than describing one, lets a house stop serving a sponsor, and states that retention is not a safety signal. It also names the adverse selection directly: a thief's willingness to bid is bounded by what it expects to steal, so the quantity this document ranks on correlates with intent to defraud, and only the catalog gate contains it.

Closing this in favour of #224.

@vincemedia vincemedia closed this Aug 16, 2026
vincemedia pushed a commit to vincemedia/BRCs that referenced this pull request Aug 16, 2026
Documentation only. Conformance vectors moved out at the maintainers'
request and now live at github.com/vincemedia/brc-conformance, linked
from the Implementations section.

Supersedes bsv-blockchain#223. Hardens the design against a malicious wallet vendor
buying its way in front of newcomers, raised in review by sirdeggen.

The auction is now explicitly a re-ordering mechanism over an
already-vetted set rather than an admission mechanism. A campaign's
wallet must already be listed in the catalog serving that site's
unsponsored slots, and the sponsor key must be attested at the vendor's
own domain per BRC-68 (4.3.8 conditions 10 and 11). The offer no longer
carries a wallet object at all: it names a catalogued wallet with
vendorKey and walletName, and every card field is rendered from the
catalog entry (3.2.1, 4.4.2), so a sponsor cannot ship its own icon,
description or install target past whoever vetted the catalog.

A house can now stop serving a sponsor and a catalog delist the wallet
(8.3.5), symmetric with the existing site rule. Retention is explicitly
not a safety signal (7.5.5), because a patient attacker's victims
satisfy it perfectly. Sponsorship is explicitly not endorsement (3.5.5).

The Security Considerations name the adverse selection directly: an
honest vendor's bounty is bounded by customer value and a thief's by
expected theft, so the quantity this document ranks on correlates with
intent to defraud, and only the catalog gate contains it. Catalog
governance is now listed as unspecified and load-bearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant