Runar covenant design
1. Why a covenant, not just a fan-out
bMovies already does fan-out transactions — the streaming engine in src/agents/piece-payment.ts broadcasts ~1.73M per 24-hour window, each one a P2PKH spend with N outputs paying holders pro-rata. That code works. But it is custodially trusted: the Platform holds the UTXO, the Platform chooses when to broadcast it, and the Platform could (in principle) spend it elsewhere.
A covenant contract changes the trust model. The ticket fee goes straight into a UTXO locked by a script that enforces three rules:
- The spending transaction must have N outputs where N matches the holder count at the snapshot block height.
- Each output must pay a specific address — the addresses committed at snapshot time.
- Each output's value must be the pro-rata share of the UTXO's total amount, proportional to each holder's share count.
Any attempt to spend the UTXO to a different destination fails the script check. The Platform cannot move the money to a wrong address, and neither can anyone else. The only valid spend is the one that distributes the revenue correctly.
2. Why not a stateful contract
Runar supports stateful smart contracts (see the stateful examples in /examples/ts/auction and /examples/ts/message-board), but they are not the right tool here. A stateful contract requires every interaction to update an on-chain state variable, which adds complexity and broadcast overhead. For dividend distribution we do not need persistent state — each ticket-sale batch creates a fresh UTXO, the covenant spends it once, and the UTXO is gone. It's a one-shot pay-out-or-die pattern, which is a stateless contract.
3. Contract shape (Runar TypeScript pseudocode)
This is the intended shape of the contract as it would be written in Runar's TypeScript DSL. It has not been compiled or deployed yet — this document is a design sketch, not a shipped artefact.
// examples/bmovies/DividendCovenant.runar.ts import { SmartContract, assert, Addr, Sats, Sig, PubKey, hash256 } from 'runar-lang'; /** * bMovies dividend distribution covenant. * * Holds ticket-sale revenue for a single production and enforces * that it may only be spent by a transaction that fans out the * total value pro-rata to the committed shareholder list. * * Constructor commitments (baked into the locking script at deploy): * - snapshotRoot: merkle root of the holder list at snapshot time * - snapshotBlockHeight: block at which the snapshot was taken * - totalShares: total outstanding shares at snapshot time * - platformFeeAddr: address of the 1% platform fee * * Spending inputs (provided at spend time): * - The ordered list of (holderAddr, shareCount) pairs * - A merkle proof tying that list to snapshotRoot */ class DividendCovenant extends SmartContract { readonly snapshotRoot: bytes32; readonly snapshotBlockHeight: u32; readonly totalShares: u64; readonly platformFeeAddr: Addr; constructor( snapshotRoot: bytes32, snapshotBlockHeight: u32, totalShares: u64, platformFeeAddr: Addr, ) { super(snapshotRoot, snapshotBlockHeight, totalShares, platformFeeAddr); this.snapshotRoot = snapshotRoot; this.snapshotBlockHeight = snapshotBlockHeight; this.totalShares = totalShares; this.platformFeeAddr = platformFeeAddr; } /** * Distribute the UTXO's full value to the committed holder list. * The spending transaction's outputs must exactly match the list. */ public distribute( holderAddrs: Addr[], holderShares: u64[], merkleProof: bytes[][], spentValue: Sats, ) { // 1. Verify the holder list matches the committed snapshot root const computedRoot = merkleRoot(holderAddrs, holderShares); assert(computedRoot == this.snapshotRoot); // 2. Verify shares sum to totalShares (no phantom holders) const shareSum = sum(holderShares); assert(shareSum == this.totalShares); // 3. Verify the spending tx has exactly N + 1 outputs // (N for holders + 1 for the 1% platform fee) assert(ctx.tx.outputs.length == holderAddrs.length + 1); // 4. Verify each output pays the correct address + amount const distributable = spentValue - (spentValue / 100); // 99% for (let i = 0; i < holderAddrs.length; i++) { const expected = (distributable * holderShares[i]) / this.totalShares; assert(ctx.tx.outputs[i].address == holderAddrs[i]); assert(ctx.tx.outputs[i].value == expected); } // 5. Verify the last output is the platform fee const platformFee = spentValue / 100; assert(ctx.tx.outputs[holderAddrs.length].address == this.platformFeeAddr); assert(ctx.tx.outputs[holderAddrs.length].value == platformFee); } }
Key design notes:
- The holder list is committed at snapshot time by baking the merkle root of
(address, shareCount)leaves into the contract constructor. This fixes the list for the life of the UTXO — if the cap table changes after the snapshot, the contract pays out the old list. This is intentional: each ticket-sale batch gets its own covenant with its own snapshot, so subsequent batches pick up new holders. - The contract is parameterized per film and per batch. Every ticket-sale batch for a film deploys a fresh instance with the current holder snapshot. This is cheap: deploy cost is a single small UTXO.
- The 1% platform fee goes to a separate output at spend time, not to a stored value. The platform fee is part of the enforced spend, not a deduction.
- The spend transaction is unforgeable: anyone can construct and broadcast it, but only a transaction matching the contract's assertions will unlock the UTXO. A third party (an agent, a node, even a shareholder) can volunteer to broadcast the distribution.
4. End-to-end flow
Phase A · Ticket sale
A viewer pays $2.99 to stream a film. The Platform's Stripe integration receives the payment, converts the 99% distributable share into $MNEE (a USD-stable BSV-21 token), and sends those sats to the covenant UTXO. The 1% platform fee is collected separately in the same checkout.
Phase B · Batch snapshot
Once a batch threshold is reached (by time — daily/weekly — or by value — e.g. $100 accumulated), the Platform fetches the current holder list from the BSV-21 indexer. Each holder's shares are counted at a specific block height. A merkle root is computed over (holderAddr, shareCount) pairs and committed as the covenant's snapshotRoot.
Phase C · Covenant deploy
The contract is compiled by Runar and deployed to a locking script on Bitcoin SV. The batch's accumulated $MNEE UTXO is sent to this locking script. From that moment on, the Platform cannot spend the UTXO elsewhere — the covenant controls it.
Phase D · Distribution spend
The Platform (or any agent, including shareholders themselves) constructs a spending transaction with N+1 outputs: one for each holder paying the pro-rata share, plus one for the 1% platform fee. The covenant script verifies the transaction structure and unlocks the UTXO. The spend is broadcast. Every holder's wallet receives a $MNEE transfer directly from the covenant, with no intermediate Platform touch.
Phase E · Audit
The covenant deploy txid and the distribution spend txid are logged to bct_distributions in the Platform database for reporting. Holders see the distribution in their app.bmovies.online/account dividend history with a link to the on-chain transaction. The audit trail is: (a) committed snapshot root, (b) covenant deploy, (c) distribution spend, (d) per-holder $MNEE receipt — all four on chain.
5. What this buys us legally
The Platform's non-custodial disclosure currently acknowledges two brief windows during which the Platform IS custodial: the commission-checkout service-fee window, and the ticket-revenue accumulation window between distribution batches. The Runar covenant eliminates the second window entirely. Once deployed:
- Ticket revenue flows directly from Stripe → $MNEE conversion → covenant UTXO. The Platform does not "hold" it at any stage; it routes it.
- The Platform cannot redirect, pause, or reverse a distribution. The covenant is the payment authority, not the Platform.
- Under UK law, this pushes the Platform further away from "operating a regulated deposit scheme" and closer to "operating a payment rail" — which has a different (lighter) regulatory footprint.
- In the event of Platform shutdown, the covenant UTXO would still be spendable by any party who has the holder list + merkle proof. Shareholders can be paid even after the Platform itself no longer exists.
The Platform still needs FCA regulatory review for the first window (commission checkout fee-in-advance), but that is a much smaller surface to defend than "we accept deposits on your behalf."
6. Scope, status, and estimates
Not yet built. This document is a design sketch, not a shipped contract. The elements that need to be built:
- The Runar contract itself — 5–8 days of contract development + testing.
- A snapshot builder that queries the BSV-21 indexer, produces the holder list, and computes the merkle root — 1–2 days.
- A deploy script that compiles the contract, funds it from ticket revenue, and broadcasts the deploy — 1 day.
- A spend constructor that reads the snapshot, builds the fan-out tx, and broadcasts it — 1 day.
- A $MNEE integration (exchange rate + conversion path) — 2–3 days depending on what $MNEE's official on-ramp looks like.
- FCA-aware legal review of the contract structure — budget £3k–£10k and 2–4 weeks of lawyer calendar.
Total: 2–3 weeks of engineering + a parallel legal review thread. Not a hackathon deliverable. It is a post-submission Phase 2 goal that finalises the non-custodial claim made in the prospectus.
7. References
- Runar repository: github.com/b0ase/runar
- Runar covenant example:
/examples/ts/covenant-vault/CovenantVault.runar.ts - Runar escrow example:
/examples/ts/escrow/Escrow.runar.ts - bMovies current streaming fan-out:
src/agents/piece-payment.ts - $MNEE (BSV USD stablecoin): check
mnee.iofor the current SDK + API
Press Cmd+P or Ctrl+P to save this design as a PDF.
Non-custodial disclosure ·
$bMovies prospectus ·
Terms of Service