feat(payments): subscription billing rearchitecture + bindings v0.16.0#132
Merged
Conversation
Closes ten prior audit findings on the subscription billing path with one cohesive redesign. Substantive contract behavior changes — downstream consumers (Rust bindings v0.16.0, indexer, dapp) must update. ## What changed **Bill amount** - TWAP-fair from activation: `nominalRate × cumDeltaPeriod / (baseline × interval)`, capped at `nominalRate`. Operators ramping stake mid-period cannot inflate a customer's bill; ramp-down still reduces it. - Baseline pinned at activation (not first bill). Removes the stake-ramp attack window that the previous lazy-init path opened. - Manager QoS hook `computeBillAdjustmentBps(serviceId, periodStart, periodEnd)` can discount the bill (clamped `[0, 10_000]`); called via gas-capped staticcall. `queryDeveloperPaymentAddress` is now gas-capped too. Malicious managers cannot grief keepers. - Per-operator TWAP cursors are mutated only AFTER the escrow-balance check passes — a failed try-bill no longer leaves cursors advanced (closes the free-period exploit on the batch path). - Dust bills (rounding to less than 1 wei per recipient) skip cleanly via `minBillAmount` rather than reverting deep in `_distributeBill`. - Overflow in `twapBillAmount` now reverts with `BillingArithmeticOverflow` instead of silently returning nominal. **Operator payout** - Same `cumDelta × exposureBps` weights drive bill amount AND payout share — customer-fairness and operator-fairness coupled. An operator who ramped stake earns a larger slice of the SAME (capped) pool. - Zero active operators: bill is skipped, cursor advances, escrow untouched, `SubscriptionBillSkippedNoOperators` emitted. No livelock when a Dynamic service loses all its operators. - Staker pool refund-to-escrow when the fee distributor is unset or reverts (was: silent capture by treasury). - `claimRewardsAll` semantics unchanged for the user; the keeper rebate uses the same pull-pattern mapping. **Payment split** - `PaymentSplit` gained a fifth field `keeperBps`. `setPaymentSplit` requires the five-field sum equal 10_000. `paymentSplit()` returns a 5-tuple. - Default split unchanged at 20/20/40/20/0 — admins opt in to a non-zero keeper rebate via `setPaymentSplit` per network policy. - Non-subscription distributions (PayOnce, RFQ, per-job) fold `keeperBps` into the operator pool so the total still sums to 10_000. **EventDriven funding** - Upfront `paymentAmount > 0` at request is rejected with `UpfrontPaymentNotAllowedForEventDriven`. Customer funds are never collected on a path that cannot pay them out properly. Quote-create flow has the matching guard. **Activation paths** - `_handleInitialPayments` (request flow) and `_activateQuoteService` (quote flow) both seed per-op TWAP cursors and pin `subscriptionBaselineStake` at activation. A bill against an unbaselined service reverts loudly with `SubscriptionBaselineNotInitialized` instead of silently re-seeding. **Per-service operator ceiling** - `ProtocolConfig.MAX_OPERATORS_PER_SERVICE = 64`. `maxOperators = 0` on a blueprint config is interpreted as "use the protocol ceiling" — never "unlimited". Bounds every per-operator loop in the bill / distribute / terminate paths. Hard fix for the storage-audit Critical that let a blueprint creator unbound the bill loop. **Events** - New: `SubscriptionBillSkippedNoOperators`, `SubscriptionBillAdjustedByManager`, `KeeperRebateAccrued`, `StakerShareRefundedToEscrow`, `SubscriptionBaselineInitialized`. - `PaymentSplitUpdated` extended with `keeperBps`. **Errors** - New: `BillingArithmeticOverflow`, `SubscriptionBaselineNotInitialized`, `UpfrontPaymentNotAllowedForEventDriven`. **Dead code removed** - `PaymentLib.calculateOperatorPayments`, `validatePaymentAmount`, `bpsShareRoundUp`, `divUp` — superseded by inline per-weight distribution and `minBillAmount`. Orphaned test file deleted. - `_seedTwapCursorForOp`, `_aggregateOperatorStakeAtNow` — unreachable helpers stripped. ## Audits Four independent reviewers ran against this branch: - Security: 0 Critical / 1 High (cursor-mutation persistence — fixed in this commit) / 1 Medium (quote-path EventDriven bypass — fixed) plus Lows - Architectural correctness: all 10 prior audit findings verified fixed - Hardening + gas + code quality: dead-code cleanup, gas captures, naming - Timing intervals + storage / O(1) audits: 3 timing Highs and 2 storage Criticals filed; storage C-1 (`maxOperators = 0` unbounded) fixed inline, remaining items captured as follow-up issues. ## Tests - New `test/security/SubscriptionBilling.t.sol` (renamed from F5TWAPBilling): 15 tests — 7 unit + 6 fuzz (× 1000 runs each), covering bill cap, zero-op skip, keeper rebate, QoS waiver, EventDriven rejection, try-bill cursor invariant, and overflow revert. - Updated all PaymentSplit literals + destructures across `test/` and `script/` for the 5-tuple. - Fixed two pre-existing test failures rooted in the same via-IR optimizer quirk on `vm.warp` + `block.timestamp`: `test_GovernorSelfUpgrade` / `test_TokenUpgradeViaGovernance` (Governance), and `testFuzz_DisputeWindow_Enforcement` (SlashingFuzz, 1000 runs). - Full regression: 1445 tests pass across 94 suites. ## Bindings + docs - `bindings v0.16.0`: regenerated, CHANGELOG written. - `docs/PRICING.md` rewritten end-to-end against v0.16.0 source. - `README.md` updated for the 5-tuple split + new payment-model summary. - `docs/DEPLOYMENT_RUNBOOK.md` + `docs/full-deploy.md` path corrections. - Stale AUDIT-*.md / pursue-launch-readiness.md / COMMIT_MSG.md removed. ## Follow-up (NOT in this PR) Captured for future PRs: - Storage C-2: O(1) operator-asset stake aggregate (perf, not correctness). - Storage H-1: ValidatorPodManager unbounded delegator array on slash. - Storage H-3: `claimRewardsAll` try/catch on per-token transfer. - Timing H-1: Job RFQ quote `requester` binding not verified. - Timing H-2: Service-creation quote freshness gap. - Timing H-3: `extendServiceFromQuotes` cumulative TTL cap. - Contract-size: TanglePaymentsFacet + TangleServicesFacet exceed EIP-170 (deploys on anvil require `--disable-code-size-limit`); needs facet split before mainnet.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes ten prior audit findings on the subscription billing path with one cohesive redesign. Substantive contract-behavior changes — Rust bindings, indexer, and dapp must update.
Bill amount
nominalRate × cumDeltaPeriod / (baseline × interval), clamped tonominalRate. Operators ramping stake mid-period cannot inflate a customer's bill; ramp-down still reduces it. Kills both customer-overpayment surprise AND the rate-vs-amount termination livelock window.computeBillAdjustmentBps(serviceId, periodStart, periodEnd) → uint16can discount the bill (clamped `[0, 10_000]`), called via gas-capped staticcall.queryDeveloperPaymentAddressis now gas-capped too. Malicious managers cannot grief keepers.minBillAmountrather than reverting deep in_distributeBill. A manager hook returning a tinyqosBpscan't brick a service.BillingArithmeticOverflowinstead of silently returning nominal.Operator payout
weights[i] = cumDelta[i] × exposureBps[i]. Customer-fairness and operator-fairness coupled — an operator who ramps stake earns a larger slice of the SAME (capped) pool.SubscriptionBillSkippedNoOperators, escrow untouched. No livelock when a Dynamic service loses all operators.StakerShareRefundedToEscrowwith reason.Payment split
PaymentSplitgained a fifth fieldkeeperBps.setPaymentSplitrequires the 5-field sum equal 10_000.paymentSplit()returns a 5-tuple.setPaymentSplitper network policy.keeperBpsinto the operator pool so totals still sum to 10_000.EventDriven funding
paymentAmount > 0at request rejected withUpfrontPaymentNotAllowedForEventDriven. Customer funds never collected on a path that can't pay them out properly. Quote-create flow has the matching guard.Activation paths
_handleInitialPayments(request flow) and_activateQuoteService(quote flow) both seed per-op TWAP cursors and pinsubscriptionBaselineStakeat activation. A bill against an unbaselined service reverts loudly withSubscriptionBaselineNotInitializedinstead of silently re-seeding.Per-service operator ceiling
ProtocolConfig.MAX_OPERATORS_PER_SERVICE = 64.maxOperators = 0on a blueprint config is interpreted as "use protocol ceiling" — never "unlimited". Bounds every per-operator loop in the bill / distribute / terminate paths. Closes the storage-audit Critical that let a blueprint creator unbound the bill loop.Events / errors / dead code
SubscriptionBillSkippedNoOperators,SubscriptionBillAdjustedByManager,KeeperRebateAccrued,StakerShareRefundedToEscrow,SubscriptionBaselineInitialized.PaymentSplitUpdatedextended withkeeperBps.BillingArithmeticOverflow,SubscriptionBaselineNotInitialized,UpfrontPaymentNotAllowedForEventDriven.PaymentLib.calculateOperatorPayments,validatePaymentAmount,bpsShareRoundUp,divUp,_seedTwapCursorForOp,_aggregateOperatorStakeAtNow. Orphanedtest/payments/EffectiveExposurePayments.t.soldeleted.Audits
Four independent reviewers ran against this branch (security, architectural correctness, hardening/gas/code-quality, timing+storage). Their actionable findings:
QuotesCreate)maxOperators = 0unboundedMAX_OPERATORS_PER_SERVICE = 64)Tests
test/security/SubscriptionBilling.t.sol(renamed fromF5TWAPBilling.t.sol): 15 tests — 7 unit + 6 fuzz × 1000 runs. Covers bill cap, zero-op skip, keeper rebate, QoS waiver, EventDriven rejection, try-bill cursor invariant, overflow revert.test/andscript/for the 5-tuple.main, both rooted in the same via-IR optimizer quirk where the optimizer cachesblock.timestampacrossvm.warpcalls:test_GovernorSelfUpgrade/test_TokenUpgradeViaGovernanceinGovernance.t.sol, andtestFuzz_DisputeWindow_EnforcementinSlashingFuzz.t.sol(1000 runs).Bindings + docs
bindings v0.16.0regenerated; CHANGELOG written. Will publish to crates.io after merge.docs/PRICING.mdrewritten end-to-end against the v0.16.0 source.README.mdupdated for the 5-tuple split + payment-model summary.docs/DEPLOYMENT_RUNBOOK.md+docs/full-deploy.mdpath corrections.AUDIT-*.md,pursue-launch-readiness.md,COMMIT_MSG.mdremoved.Local testnet smoke
Deployed end-to-end against a fresh anvil and exercised: PaymentSplit 5-tuple return, Subscription escrow drain, operator pending rewards, EventDriven
UpfrontPaymentNotAllowedForEventDrivenrevert at request. All ✅.Flagged from smoke:
TanglePaymentsFacet+TangleServicesFacetexceed EIP-170 (24,576 bytes) — anvil deploys only with--disable-code-size-limit. Will block mainnet/L2 deployment until the facets are split. Captured below.Follow-up (separate PRs)
_operatorStakeForAssetaggregate to remove the per-bill stake-derivation loop.claimRewardsAllper-token transfer.requesteron job-RFQ quote verification.maxQuoteAgeon service-creation quotes.extendServiceFromQuotes.TanglePaymentsFacetandTangleServicesFacetto meet EIP-170 before mainnet.Test plan
forge buildcleanforge test --no-match-path test/Integration.t.sol: 1445/1445 ✅test/security/SubscriptionBilling.t.sol: 15/15 ✅ (incl. 6 × 1000 fuzz runs)tnt-core-bindings v0.16.0to crates.io after mergePaymentSplitABI break — separate PRs in those repos