feat: VariableDividendPreferred — variable-rate perpetual preferred example#40
feat: VariableDividendPreferred — variable-rate perpetual preferred example#40tiero wants to merge 1 commit into
Conversation
A Bitcoin-native perpetual preferred share paying a variable cash dividend, callable by the issuer at par + accrued. A $100-par claim whose dividend rate is re-pegged to keep the share trading near par. Dividends are denominated in USD cents and paid in BTC (sats) at an oracle-attested BTC/USD price. Dividend design: - Continuous pro-rata accrual on par at dividendRateBps. - Cumulative: accruedCents only ever resets on a real payment (claim/redeem). - Deterministic re-peg from a signed secondary-price oracle feed: below par forces the rate up, above par down, clamped to [min,max]. - Arrears teeth: self-revealing coverage (declaring "healthy" forces full reserve coverage; otherwise an honest, non-resettable arrears clock starts). Past grace the effective rate jumps to penaltyRateBps and the holder may forceRedeem. pokeArrears is permissionless so the issuer can't dodge it. Tests: roundtrip structural invariants + behavioral coverage (oracle message reconstruction, pure key-swaps, permissionless poke, timelocked exit variants).
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Playground PreviewA live preview of this PR's playground is available at:
|
There was a problem hiding this comment.
Arkana Code Review — VariableDividendPreferred
Thorough review. The contract is well-documented and the dividend/arrears model is clever. However, there are critical value-preservation gaps that would allow reserve drainage, rendering the arrears protection hollow.
🔴 CRITICAL — Permissionless pokeArrears allows reserve drain
examples/variable_dividend_preferred.ark:170 — pokeArrears is intentionally permissionless (no issuer/holder sig), but the only output-value check is tx.outputs[0].value >= 330 (line 170). Anyone who can construct a valid transaction (cooperative path with ASP, or after CSV expiry on exit path) can:
- Call
pokeArrearswith a valid oracle message - Set
tx.outputs[0].value = 330(dust floor) - Route the remaining reserve to an output they control
Existing permissionless functions in this repo (e.g., BondMint.liquidate) either enforce exact value on outputs or are paired with co-spent contracts that handle precise accounting. pokeArrears has neither.
Fix: At minimum, enforce tx.outputs[0].value >= tx.inputs[0].value (value preservation), or require the output value covers accrued + some margin. Alternatively, gate pokeArrears on a signature (holder or a watchtower key) while still keeping it independent of the issuer.
🔴 CRITICAL — accrueAndRepeg lets issuer drain reserve silently
examples/variable_dividend_preferred.ark:118-119 — Same pattern: tx.outputs[0].value >= 330. The issuer calls accrueAndRepeg, sets output[0] to 330 sats, and pockets the rest. The arrears clock doesn't even start because arrearsSince is carried unchanged — the holder has to notice and call pokeArrears, wait for the grace period, then forceRedeem… 330 sats.
This makes the entire arrears protection mechanism hollow. The issuer can drain first, face consequences never.
Fix: accrueAndRepeg should preserve value: tx.outputs[0].value >= tx.inputs[0].value (or at least the current UTXO value). The issuer already has topUp and can withdraw excess through a dedicated function with proper guardrails.
🟡 HIGH — Integer overflow on faceValue * units and newAccrued * 100000000
examples/variable_dividend_preferred.ark:102,103 (and repeated in every function) — int notional = faceValue * units can overflow int64 if both are large. Similarly, newAccrued * 100000000 (line 152) overflows when newAccrued > ~92 billion cents (~$920M).
Existing contracts in this repo document the overflow ceiling (bond_mint comments reference ~92 BTC). This contract should either:
- Add
require()guards onfaceValueandunitsbounds, or - Document the safe operating range explicitly
🟡 HIGH — delta > 0 requirement blocks rapid repeg
examples/variable_dividend_preferred.ark:105 — require(delta > 0, "no accrual; wait longer") means you cannot repeg without non-zero accrual. Due to integer truncation in rateElapsed = effRate * elapsed / 31536000, small elapsed values produce rateElapsed = 0, so delta = 0.
For a 500 bps rate, minimum elapsed before any accrual: 31536000 / 500 = 63072 seconds ≈ 17.5 hours. During volatile markets, being unable to repeg for 17+ hours defeats the purpose of the re-peg mechanism.
Fix: Allow delta >= 0 (zero accrual is fine; the state change is the repeg itself). Or separate the repeg from the accrual into distinct functions.
🟡 MEDIUM — topUp allows value decrease
examples/variable_dividend_preferred.ark:216 — require(tx.outputs[0].value >= 330). The function is called "topUp" but there's no enforcement that the output value exceeds the input value. An issuer could "top up" while actually withdrawing funds.
Fix: Enforce tx.outputs[0].value > tx.inputs[0].value or at minimum tx.outputs[0].value >= currentValue if input introspection is available.
🟡 MEDIUM — claim clears arrears even when structurally underfunded
examples/variable_dividend_preferred.ark:199-200 — A successful claim sets both accruedCents = 0 and arrearsSince = 0. But paying a small accrued dividend doesn't mean the reserve adequately covers future obligations. An issuer can keep the reserve barely above the current accrued amount, claim clears the clock, and repeat — never building a meaningful reserve.
This is a design choice but weakens the arrears protection. Consider: arrears should only clear when reserve coverage is verified (as in pokeArrears healthy-declaration logic).
🟢 LOW — No constructor validation of faceValue > 0, units > 0
If a contract is instantiated with faceValue = 0 or units = 0, then notional = 0 → delta = 0 → every function fails on require(delta > 0). The contract becomes permanently stuck. Not a security issue but a footgun.
🟢 LOW — forceRedeem seizure may be worthless
examples/variable_dividend_preferred.ark:339 — require(tx.outputs[0].value >= 330). If the reserve has already been drained (via the value-preservation gaps above), forceRedeem seizes 330 sats. The "teeth" have no bite. This is downstream of the critical issues above — fixing value preservation fixes this.
Tests
The tests (variable_dividend_preferred_test.rs) are well-structured and verify the right structural invariants (oracle verification, permissionless pokeArrears, timelocked exits). However:
- No tests verify value-preservation semantics (because the contract doesn't enforce them)
- No overflow scenario coverage
- No test for the repeg math with small elapsed intervals
Summary
The dividend model is sound and the arrears design is thoughtful, but value preservation is not enforced, which is a pattern violation vs. other contracts in this repo. The critical issues (reserve drainage via pokeArrears and accrueAndRepeg) must be fixed before merge — they make the entire arrears protection mechanism a dead letter.
Requesting changes on the two critical items. Happy to re-review once addressed.
⚡ Reviewed by Arkana
Summary
Adds a new example contract,
examples/variable_dividend_preferred.ark: a Bitcoin-native perpetual preferred share paying a variable cash dividend, callable by the issuer at par + accrued. It's a $100-par claim whose dividend rate is continuously re-pegged to keep the share trading near par — a generalized, on-chain model of a variable-rate perpetual preferred. Dividends are denominated in USD cents and paid in BTC (sats) at an oracle-attested BTC/USD price.This is an example/reference contract (in the same family as
stability/,bonds/, andoptions/), not a change to the compiler itself.Dividend design
faceValue × units) atdividendRateBps, using interleaved divides to stay inside int64.accruedCentsis only ever reset to 0 by an actual payment (claim/redeem); there is no partial-claim path, so the debt accounting stays honest.[rateMinBps, rateMaxBps]. The issuer chooses when to re-peg, never how much.arrearsSince— declaring "healthy" forces full reserve coverage, otherwise an honest, non-resettable arrears clock starts. PastgracePeriodthe effective accrual rate jumps topenaltyRateBpsand the holder mayforceRedeemthe reserve.pokeArrearsis permissionless, so the issuer can't dodge the penalty by staying idle.Functions
accrueAndRepeg,pokeArrears,claim,topUp,transfer,redeem,forceRedeem— each emitting both the cooperative and the timelock-gated unilateral-exit variant (14 tapleaves).Tests
roundtrip_variable_dividend_preferredincompilation_roundtrip_test.rs— structural invariants + 14-tapleaf count.tests/variable_dividend_preferred_test.rs— behavioral coverage: oracle message reconstruction (sha256(ticker‖price‖time)+OP_CHECKSIGFROMSTACK), pure key-swaps touch no oracle,pokeArrearsis permissionless, and every function exposes a CSV-gated exit variant.cargo fmt --checkclean; fullcargo testgreen.playground/contracts.jsregenerated locally (gitignored artifact, not committed).Notes / open design choices
Deliberately kept simple — flat penalty (no per-window ratchet), uncapped
forceRedeemseizure, and arrears enforcement living inpokeArrearsrather than folded into the re-peg. Happy to tighten any of these. Marked draft for review of the dividend/arrears model before finalizing.https://claude.ai/code/session_016X8b5rz5zPuxz8QGGCiugQ
Generated by Claude Code