Skip to content

feat(news): v5 legion - sponsorship, contributed-sats weight pricing, join floor - #11

Open
biwasxyz wants to merge 10 commits into
mainfrom
feat/news-legion-v5-sponsor
Open

feat(news): v5 legion - sponsorship, contributed-sats weight pricing, join floor#11
biwasxyz wants to merge 10 commits into
mainfrom
feat/news-legion-v5-sponsor

Conversation

@biwasxyz

@biwasxyz biwasxyz commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

What

v5 is the sponsorship model: money can enter the pool without buying governance, so a sponsor funds journalism and gets attribution instead of a vote.

It started as two changes to v4. An audit then found that the weight-less inflow broke an invariant v4's minting math silently depended on, and most of this PR is the fix for that.

1. Sponsorship

news-treasury-v5.clar adds a public sponsor-in(amount, name, link, memo) that adds sBTC to the pool and mints no voting weight.

The sponsor's identity is structured, not packed into one string, so no indexer has to agree on a separator convention to get a display name back out:

arg type role
name (string-ascii 40), non-empty the only attribution field
link (optional (string-ascii 96)) sponsors without a site skip it
memo (string-ascii 128) free-form, on the record
  • Floor of 100,000 sats (MIN_SPONSOR, ERR_BELOW_MIN u450), readable via get-min-sponsor. No ceiling: a large deposit is a good customer, not an error.
  • Gated on gov being wired (ERR_NOT_WIRED u452). Every outflow is gov-gated, so before wiring there is no path out at all, and an unwired treasury would otherwise accept money it could never spend.
  • name is an unverified claim. Any display must treat the sender principal as the authoritative identity.
  • Nothing about badge duration or ordering is on-chain. Those are off-chain rules recomputable from the events.

2. A fixed floor to join

contribute had no sats floor at all, and the real cost of joining only ever fell: weight is priced as a share of the contributed pool, and payouts shrink that pool while total weight holds. A mature legion could eventually be joined for dust.

MIN_CONTRIBUTION u10000 (ERR_BELOW_MIN_CONTRIBUTION u437) is checked against sats sent, so entry costs a fixed amount whatever the pool is worth. Set equal to MIN_WEIGHT deliberately: because WeightedBalance <= TotalWeight always holds, a contribution at the floor always mints at least MIN_WEIGHT, so whoever can join can immediately propose, vote and veto.

3. The audit fix: price weight against contributed sats

In v4 the pool moved only via contribute-in (which minted weight in the same tx) or execute-payout (which shrank it), so the mint rate could never move against a contributor and a non-empty pool implied non-zero weight. A weight-less inflow broke both halves. Two consequences, both reproduced in simnet:

  • A sponsorship landing before the first contributor left TotalWeight at zero over a funded pool, so the bootstrap branch minted a flat amount: 10,000 sats bought 100% of a 100,010,000 pool. Every piece that holder proposed then failed no-quorum, and quote-weight(100_000_000) returned 9,999, under MIN_WEIGHT, so no newcomer could clear the floor without pool-scale sats.
  • Every later sponsorship raised the price of joining: 270M sponsored against a 30M pool cut quote-weight(10_000) from 10,000 to 1,000. The more sponsorship succeeded, the more closed the legion became, inverting the point of the weight-less path.

The treasury now tracks WeightedBalance, the contributed share of the pool: raised by contribute-in only, shrunk pro-rata on payout so it stays a true fraction of Balance. Gov prices weight against it via get-weighted-balance, in both contribute and quote-weight. The draw still comes off the whole pool, so sponsor sats enlarge every payout; they simply buy no governance and move nobody's price.

Deliberate consequence, documented rather than hidden: as sponsorship grows, governance is cheap relative to the money it governs. That is the un-welding working as intended. ECONOMICS.md quantifies exactly how cheap.

4. Draw 0.01% -> 0.05%

DRAW_BPS 1 -> 5, so each approved piece pays 5x, sized against the larger pool sponsors bring in. Each draw is 0.05% of what's left, so the pool asymptotes rather than empties.

Analysis

ECONOMICS.md and ECONOMICS-PLAIN.md document the economics and attack surface. Every threshold was verified in simnet at the boundary, one sat under the prediction and one sat at it, against the PROD-BURN mainnet build:

formula A=100,000 A=1,000,000
Beat the veto 17A/3 + p 576,667 5,676,667
Beat the vote only 33A/17 + p 204,118 1,951,177
Nobody defends max(3A/17, 20k) + p 30,000 186,471

Findings worth reviewer attention:

  • Extraction is capped at one story per Bitcoin block, 144/day (PROPOSE_INTERVAL), verified. Lengthening the lifecycle windows does not slow it, since the propose gate reads none of them; it only changes how many proposer principals are needed.
  • The veto is the real defence and is worth ~19x, but it needs someone watching within VETO_WINDOW.
  • Lowering VETO_QUORUM 15% -> 5% raises attack cost 3.35x and is the strongest available lever. Not applied here; it is a product call.

Contracts

  • news-treasury-v5.clar - v4 treasury + sponsor-in, WeightedBalance, MIN_SPONSOR
  • news-gov-v5.clar - v4 gov + DRAW_BPS u5, MIN_CONTRIBUTION, priced against get-weighted-balance
  • news-gov-v5-testnet.clar - generated by scripts/gen-testnet-gov.mjs, never hand-edited
  • deployments/news-v5.testnet-plan.yaml - fresh treasury, same deployer, treasury first

Also annotates propose-story with #[allow(unchecked_data)] in all five gov contracts, clearing five pre-existing clarinet warnings. Comment-only, no behavior change. Note news-gov.clar and news-gov-v4-testnet.clar are deployed, so their source now differs from chain by that one line.

Tests

tests/news-v5.test.ts - 64 cases. Full suite 179 green, clarinet check clean with zero warnings.

New coverage targets the gap that let the audit findings through: sponsor-in composed with contribute, which nothing previously exercised. Plus a check pinning the four sBTC principals to each other and to an expected value, so a partial or typo'd mainnet swap cannot ship a contract that takes deposits in one token and pays out in another.

Not done / open

  • Not deployed. v5 has never been on testnet; the plan is committed but unrun.
  • The v4 pool does not migrate (29,997,000 sats of testnet faucet sBTC, deliberately written off). Gov v5 hardcodes .news-treasury-v5 at 8 sites, so this repeats for any v6. On mainnet that pool would be real money.
  • Sponsor strings are unvalidated beyond a non-empty name. Escaping and link allow-listing are the app's job.
  • news-legion cannot see v5 or any sponsor-in event: its chainhook filters on the gov contract, and sponsorships print on the treasury.
  • A mainnet deploy must swap all four sBTC principals to SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token.

biwasxyz added 9 commits July 26, 2026 18:29
v5 = v4 with exactly two changes, for the sponsorship model:

- news-treasury-v5.clar: adds a public sponsor-in(amount, memo) that funds the
  pool WITHOUT minting voting weight. A sponsor (or an agent on their behalf)
  pays for an ad, the money goes to the pool, and "sponsored by xyz" is rendered
  off-chain by the news site, so no governance power is gained. contribute-in
  (weight-minting) is unchanged; only sponsor money is un-welded from governance.
- news-gov-v5.clar: DRAW_BPS 1 -> 5, so the draw is 0.05% (5x v4). Agents earn
  more per approved piece, sized against the larger pool sponsors bring in.

news-gov-v5-testnet.clar regenerated by scripts/gen-testnet-gov.mjs (stacks
blocks, DRAW_BPS 5). Tests: tests/news-v5.test.ts (51 cases incl. sponsor-in);
full suite 166 green; clarinet check clean (8 contracts).

Sponsorship needs no other on-chain machinery: the "sponsored by" badge and the
deposit-to-story link live on the frontend; the txid is the sponsor's proof.
sponsor-in previously took one free-form memo, which forced every reader
to agree on a separator convention to get a display name back out of it.
The identity is now three typed arguments: a required name (the only
attribution field), an optional link, and a free-form memo. The event
prints them as distinct fields, so an indexer reads the name directly and
parses nothing.

Adds a MIN_SPONSOR floor of 100,000 sats (ERR_BELOW_MIN u450), readable
via get-min-sponsor so callers need not hardcode it, and rejects an
unnamed sponsor (ERR_EMPTY_NAME u451). There is deliberately no ceiling:
a large deposit is a good customer, not an error. The explicit zero check
is gone from sponsor-in only, since the floor subsumes it.

The name is an UNVERIFIED claim. Any display must treat the sender
principal as the authoritative identity. Nothing about sponsorship
duration or ordering lives on-chain; those are off-chain rules anyone can
recompute from these events.

Also annotates propose-story with #[allow(unchecked_data)] in all five
gov contracts, clearing five pre-existing clarinet warnings. Comment
only, no behavior change: description is stored unvalidated alongside a
length-checked title and link, and the checker flagged the odd one out.

170 tests green, clarinet check clean.
An audit of v5 found that sponsor-in broke an invariant v4's minting math
depended on. In v4 the pool moved only via contribute-in (which minted
weight in the same tx) or execute-payout (which shrank it), so the mint
rate could never move against a contributor and a non-empty pool implied
non-zero weight. sponsor-in is a weightless inflow and broke both halves.

Two confirmed consequences, reproduced in simnet:

  - A sponsorship landing before the FIRST contributor left TotalWeight at
    zero over a funded pool, so contribute's bootstrap branch minted a flat
    `amount`: 10,000 sats bought 100% of a 100,010,000 pool. Every piece
    that holder proposed then failed no-quorum (eligibleSnapshot u0), and
    quote-weight(100_000_000) returned 9,999, under MIN_WEIGHT, so no
    newcomer could clear the floor without paying pool-scale sats.
  - Every later sponsorship raised the price of joining: 270M sponsored
    against a 30M pool cut quote-weight(10_000) from 10,000 to 1,000. The
    more sponsorship succeeded, the more closed the legion became, which
    inverts the stated point of the weightless path.

The treasury now tracks WeightedBalance, the contributed share of the
pool, incremented by contribute-in only and shrunk pro-rata on payout so
it stays a true fraction of Balance. Gov prices new weight against that
via get-weighted-balance, in both contribute and quote-weight. The draw
still comes off the WHOLE pool, so sponsor sats enlarge every payout;
they simply buy no governance and move nobody's price.

Deliberate consequence: as sponsorship grows, governance is cheap relative
to the money it governs, since only contributors hold weight. That is the
un-welding working as intended, not a regression.

Also gates sponsor-in on gov being wired. It was the only entry point not
already gov-gated, and every outflow is, so before wiring it accepted
money into a treasury with no path out at all.

Tests: 6 new regressions covering sponsor-in COMPOSED with contribute,
which is the gap that let all of this through, plus a check pinning the
four sBTC principals together so a partial mainnet swap cannot ship a
contract that takes deposits in one token and pays out in another.
176 green, clarinet check clean.
…-division guarantee

Round-2 audit follow-ups, all small.

sponsor-in returned ERR_UNAUTHORIZED when gov was unwired, which reads as
"you are not allowed" when it means "not ready yet". A sponsor UI cannot
tell those apart. Now ERR_NOT_WIRED (u452).

The M3 test pinned the four sBTC principals to each other but not to a
value, so a mainnet swap that changed all four consistently to a typo'd
principal still passed. It now asserts the expected principal.

WeightedBalance reaching u0 while TotalWeight is > u0 would reopen gov's
bootstrap branch and with it the original capture. Floor division prevents
it, since (/ (* weighted amount) bal) equals weighted only for a payout
taking the entire pool, which DRAW_BPS u5 cannot produce. That guarantee
was implicit; it is now written down, because a larger DRAW_BPS or any
admin drain would silently break it.

176 green, clarinet check clean.
contribute had no sats floor at all. It rejected only a zero amount and a
contribution too small to mint any weight, so the real cost of joining was
whatever the pool happened to make it.

That cost only ever falls. Weight is priced as a share of the CONTRIBUTED
pool, and a payout shrinks WeightedBalance while TotalWeight holds, so the
sats needed to reach MIN_WEIGHT drop every time a story is paid. Sponsor
deposits and other contributions are rate-neutral, so nothing pushes it
back up. A mature legion could eventually be joined for dust, and full
voting rights were already ten times cheaper than a sponsor badge.

MIN_CONTRIBUTION u10000 is checked against the amount sent, not the weight
it mints, so the entry price is a fixed number of sats whatever the pool
is worth. It subsumes the old zero-amount check, which is removed along
with its now-unused error constant; the dust check stays as a backstop.

Set equal to MIN_WEIGHT deliberately. WeightedBalance <= TotalWeight always
holds, so minted is never less than amount and a contribution at the floor
always mints at least MIN_WEIGHT. Whoever can join can propose, vote and
veto immediately: there is no tier of holders who paid in but cannot act.

Also exposes minContribution in get-params so a UI reads it from chain.

179 green, clarinet check clean.
@biwasxyz biwasxyz changed the title feat(news): v5 - weight-less sponsor deposits + 0.05% draw feat(news): v5 legion - sponsorship, contributed-sats weight pricing, join floor Jul 27, 2026
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