|
| 1 | +// DividendTreasury Contract |
| 2 | +// |
| 3 | +// The issuer-funded reserve of a streaming-dividend program: a corporate |
| 4 | +// treasury (e.g. a Bitcoin treasury company's income-generation desk) escrows |
| 5 | +// BTC that services dividend claims from StreamingShare positions. The |
| 6 | +// dividend is fixed in USD cents per unit per year, accrues continuously at |
| 7 | +// one-second granularity (tx.offchainTime), and is paid in sats at an |
| 8 | +// oracle-attested BTC/USD price at claim time. |
| 9 | +// |
| 10 | +// ── Why streaming ─────────────────────────────────────────────────────────── |
| 11 | +// Batch dividend rails need record dates, transfer agents and distribution |
| 12 | +// runs; paying monthly is already exotic in most listed markets. Here the |
| 13 | +// entitlement accrues per second and any holder can settle their accrued |
| 14 | +// balance whenever it exceeds dust — the record date disappears because |
| 15 | +// accrual follows the position continuously (see dividend_stream.md). |
| 16 | +// |
| 17 | +// ── Two contracts, one program ────────────────────────────────────────────── |
| 18 | +// DividendTreasury (this file) — singleton reserve. Pays claims, accepts |
| 19 | +// top-ups from the income desk, lets the issuer recall unallocated |
| 20 | +// reserve (telegraphed on-chain; see trust model in dividend_stream.md). |
| 21 | +// StreamingShare — per-holder position carrying (units, lastClaim). A claim |
| 22 | +// co-spends both: the treasury verifies the position is genuine and that |
| 23 | +// its accrual basis advances; the position verifies the holder is paid. |
| 24 | +// |
| 25 | +// ── Claim transaction layout (service ⊗ claim) ────────────────────────────── |
| 26 | +// input[srvIdx]: this treasury (function: service) |
| 27 | +// input[posIdx]: a StreamingShare (function: claim) |
| 28 | +// output[0]: treasury re-created, reserveSats − payoutSats |
| 29 | +// output[1]: position re-created, lastClaim = tx.offchainTime |
| 30 | +// output[2]: payoutSats → holder (SingleSig) |
| 31 | +// |
| 32 | +// ── Accrual math (shared with StreamingShare) ─────────────────────────────── |
| 33 | +// elapsed = tx.offchainTime − posLastClaim [seconds] |
| 34 | +// accruedCents = units × annualDividendCents × elapsed / 31536000 |
| 35 | +// payoutSats = accruedCents × 1e8 / oraclePrice [price in cents/BTC] |
| 36 | +// int64 bound: units × annualDividendCents × elapsed < 9.2e18 — e.g. 1e6 |
| 37 | +// units at $6.00/yr (600 cents) unclaimed for 10 years is 1.9e17, safe. |
| 38 | +// Truncation loses < 1 cent per claim; the accruedCents > 0 guard prevents |
| 39 | +// a zero-payout claim from advancing lastClaim (no accrual griefing). |
| 40 | +// |
| 41 | +// ── Oracle model (Fuji-style signed price feed) ───────────────────────────── |
| 42 | +// msg = sha256(ticker || price || timestamp), price/timestamp 8-byte LE. |
| 43 | +// Freshness: tx.offchainTime − oracleTime within [0, 600] seconds. |
| 44 | + |
| 45 | +import "single_sig.ark"; |
| 46 | +import "streaming_share.ark"; |
| 47 | + |
| 48 | +contract DividendTreasury( |
| 49 | + pubkey issuerPk, // issuer treasury key: recall + unilateral exit |
| 50 | + pubkey oraclePk, // BTC/USD signed price feed |
| 51 | + bytes32 ticker, // feed id, e.g. sha256("BTC/USD") |
| 52 | + int annualDividendCents, // fixed dividend per unit per year, USD cents |
| 53 | + int totalUnits, // units outstanding across all positions |
| 54 | + int reserveSats, // sats escrowed for dividends; mutates on every spend |
| 55 | + int exit // exit timelock in blocks (shared with positions) |
| 56 | +) { |
| 57 | + |
| 58 | + // ------------------------------------------------------------------------- |
| 59 | + // TOP UP — permissionless. The income desk (or anyone) streams revenue into |
| 60 | + // the reserve. No sats can leave: the reserve only grows. |
| 61 | + // output[0]: treasury re-created with reserveSats + amount |
| 62 | + // ------------------------------------------------------------------------- |
| 63 | + function topUp(int amount) { |
| 64 | + require(amount > 0, "zero amount"); |
| 65 | + |
| 66 | + int newReserve = reserveSats + amount; |
| 67 | + |
| 68 | + require( |
| 69 | + tx.outputs[0].scriptPubKey == new DividendTreasury( |
| 70 | + issuerPk, oraclePk, ticker, annualDividendCents, totalUnits, |
| 71 | + newReserve, exit |
| 72 | + ), |
| 73 | + "invalid treasury recreation" |
| 74 | + ); |
| 75 | + require(tx.outputs[0].value >= newReserve, "reserve not deposited"); |
| 76 | + } |
| 77 | + |
| 78 | + // ------------------------------------------------------------------------- |
| 79 | + // SERVICE — permissionless servicing of one position's claim (the position |
| 80 | + // input carries the holder's authorization). Verifies the co-spent input is |
| 81 | + // a genuine StreamingShare with the witness-declared state, recomputes the |
| 82 | + // accrual independently, pays the holder, and requires the position to be |
| 83 | + // re-created with its accrual basis advanced to now — the reserve can only |
| 84 | + // leave against dividend time that is simultaneously consumed. |
| 85 | + // ------------------------------------------------------------------------- |
| 86 | + function service( |
| 87 | + int posIdx, |
| 88 | + pubkey posHolderPk, |
| 89 | + int posUnits, |
| 90 | + int posLastClaim, |
| 91 | + int oraclePrice, |
| 92 | + int oracleTime, |
| 93 | + signature oracleSig |
| 94 | + ) { |
| 95 | + require(this.activeInputIndex != posIdx, "self-service disallowed"); |
| 96 | + require(posUnits > 0, "empty position"); |
| 97 | + require(posUnits <= totalUnits, "position exceeds issue"); |
| 98 | + require(oraclePrice > 0, "invalid oracle price"); |
| 99 | + |
| 100 | + require( |
| 101 | + tx.inputs[posIdx].scriptPubKey == new StreamingShare( |
| 102 | + posHolderPk, issuerPk, oraclePk, ticker, |
| 103 | + annualDividendCents, posUnits, posLastClaim, exit |
| 104 | + ), |
| 105 | + "input not matching position" |
| 106 | + ); |
| 107 | + |
| 108 | + int oracleAge = tx.offchainTime - oracleTime; |
| 109 | + require(oracleAge >= 0, "future-dated oracle"); |
| 110 | + require(oracleAge <= 600, "stale oracle"); |
| 111 | + let oracleMsg = sha256(ticker + oraclePrice + oracleTime); |
| 112 | + require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), "invalid oracle signature"); |
| 113 | + |
| 114 | + int elapsed = tx.offchainTime - posLastClaim; |
| 115 | + require(elapsed >= 0, "clock regression"); |
| 116 | + int annualCents = posUnits * annualDividendCents; |
| 117 | + int accruedCents = annualCents * elapsed / 31536000; |
| 118 | + require(accruedCents > 0, "nothing accrued"); |
| 119 | + int payoutSats = accruedCents * 100000000 / oraclePrice; |
| 120 | + require(payoutSats > 330, "dust claim; accrue longer"); |
| 121 | + require(payoutSats <= reserveSats, "reserve exhausted"); |
| 122 | + |
| 123 | + int newReserve = reserveSats - payoutSats; |
| 124 | + |
| 125 | + require( |
| 126 | + tx.outputs[0].scriptPubKey == new DividendTreasury( |
| 127 | + issuerPk, oraclePk, ticker, annualDividendCents, totalUnits, |
| 128 | + newReserve, exit |
| 129 | + ), |
| 130 | + "invalid treasury recreation" |
| 131 | + ); |
| 132 | + require(tx.outputs[0].value >= newReserve, "reserve not preserved"); |
| 133 | + |
| 134 | + require( |
| 135 | + tx.outputs[1].scriptPubKey == new StreamingShare( |
| 136 | + posHolderPk, issuerPk, oraclePk, ticker, |
| 137 | + annualDividendCents, posUnits, tx.offchainTime, exit |
| 138 | + ), |
| 139 | + "position basis not advanced" |
| 140 | + ); |
| 141 | + require(tx.outputs[1].value >= 330, "position output dust"); |
| 142 | + |
| 143 | + require(tx.outputs[2].scriptPubKey == new SingleSig(posHolderPk, exit), "output 2 not holder"); |
| 144 | + require(tx.outputs[2].value >= payoutSats, "holder underpaid"); |
| 145 | + } |
| 146 | + |
| 147 | + // ------------------------------------------------------------------------- |
| 148 | + // RECALL — issuer-gated reserve withdrawal (program wind-down or resizing). |
| 149 | + // Recalls are visible on-chain the moment they happen, so holders can see |
| 150 | + // reserve coverage shrink and act; the pilot trust model and its hardening |
| 151 | + // path (timelocked recall notice, arrears penalties) are documented in |
| 152 | + // dividend_stream.md. |
| 153 | + // output[0]: treasury re-created with reserveSats − amount |
| 154 | + // output[1]: amount → issuer |
| 155 | + // ------------------------------------------------------------------------- |
| 156 | + function recall(signature issuerSig, int amount) { |
| 157 | + require(checkSig(issuerSig, issuerPk), "invalid issuer sig"); |
| 158 | + require(amount > 0, "zero amount"); |
| 159 | + require(amount <= reserveSats, "exceeds reserve"); |
| 160 | + |
| 161 | + int newReserve = reserveSats - amount; |
| 162 | + |
| 163 | + require( |
| 164 | + tx.outputs[0].scriptPubKey == new DividendTreasury( |
| 165 | + issuerPk, oraclePk, ticker, annualDividendCents, totalUnits, |
| 166 | + newReserve, exit |
| 167 | + ), |
| 168 | + "invalid treasury recreation" |
| 169 | + ); |
| 170 | + require(tx.outputs[0].value >= newReserve, "reserve not preserved"); |
| 171 | + |
| 172 | + require(tx.outputs[1].scriptPubKey == new SingleSig(issuerPk, exit), "output 1 not issuer"); |
| 173 | + require(tx.outputs[1].value >= amount, "issuer underpaid"); |
| 174 | + } |
| 175 | + |
| 176 | + // ------------------------------------------------------------------------- |
| 177 | + // UNILATERAL EXIT (CSV) — issuer recovers the reserve if the Arkade |
| 178 | + // operator goes offline. The issuer is the residual claimant of the |
| 179 | + // reserve; holder positions carry their own exit leaf. |
| 180 | + // ------------------------------------------------------------------------- |
| 181 | + function unilateral(signature issuerSig) tapscript { |
| 182 | + require(older(exit)); |
| 183 | + require(checkSig(issuerSig, issuerPk)); |
| 184 | + } |
| 185 | +} |
0 commit comments