PoC: extension reward reporting + expansion (Daml) - #8
Conversation
e8c4ee1 to
bf26a68
Compare
2bb23d4 to
7ae0c9b
Compare
bf26a68 to
ff3e4ce
Compare
7ae0c9b to
39cfab1
Compare
…) [ci] The registered synchronizer's operator commits its per-round activity on the decentralized synchronizer and expands the commitment into reward coupons through the CIP-104 machinery: - DedicatedSynchronizerState: per-synchronizer reporting state; the operator's ReportActivity choice records a Merkle root hash + round total, enforces strictly increasing rounds, accumulates the total ever reported, and creates a per-round ExtensionActivityReport - AmuletRules_StartProcessingExtensionRewardsV2 (vote-dispatched via appended CRARC constructor): consumes a report and creates the root ProcessRewardsV2 with the report's root hash, the issuance rate, and the operator as expander - ProcessRewardsV2: appended expander + issuancePerActivityUnit; the expander is observer and ProcessBatch controller when set; children inherit both; leaves mint activity weight * rate. None/None is unchanged gsync behavior - Daml Script tests: report/expand happy path with coupon amounts, report monotonicity + authorization negatives, hash-binding negatives, and the 4-SV vote path end to end Signed-off-by: Timothy Wu <tim.wu@chainsafe.io>
CI's output scanner fails jobs on any DsWarning even when sbt succeeds: - replace the 8-tuple setup return with a ReportedActivitySetup record (Daml has no instances for tuples of size > 5) Review-pass fixes bundled in: - StartProcessingExtensionRewardsV2 doc comment moved to the -- ^ form after the choice declaration - roundClosedAt field doc covers the extension case (start-of-processing time; reports carry no round-close time) - new dry-run expansion test (contracts consumed, no coupons minted) Signed-off-by: Timothy Wu <tim.wu@chainsafe.io>
…als [ci] Close the gap between an extension's reported activity total and what its committed tree can mint: - StartProcessingExtensionRewardsV2 takes the vote-attested purchasedTotal and requires reported activity <= purchased traffic - BatchOfWeightedBatches (appended Batch constructor): internal nodes of an extension tree declare each child subtree's weight inside the hashed commitment; ProcessRewardsV2 carries a weightBudget threaded from the reported total, and expansion requires each level's declared weights to fit its budget, bounding total minting by the reported total - path exclusivity: unweighted internal nodes only without a rate (the decentralized synchronizer's own flow, which is unchanged - its trees are recomputed and vote-confirmed by every SV, so no budget is needed); weighted nodes only with one - tests: over-declared internal node, over-weight leaf, report exceeding purchased, plain-batch smuggling Signed-off-by: Timothy Wu <tim.wu@chainsafe.io>
39cfab1 to
e002dad
Compare
|
The SV's reward trigger will pick these up and get stuck
It then can't do anything with them: the batch data belongs to the operator, Scan has no way to serve it ( It's a polling trigger, so it retries forever on whichever SV currently holds the DSO delegate role. The Daml tests don't catch this because they expand directly as the operator, never through the trigger. Fix, which I think belongs in this PR since it's what introduces the condition: and acs.create_arguments->>'expander' is nullThe operator's trigger (ChainSafe/canton-extending-mainnet#58) filters the other way, so the two split the work cleanly. Update: scope correction, this shouldn't land in this PR. E10-1 is Daml-only by design ("don't hook it up and run it"), and the problem is latent anyway: an extension ProcessRewardsV2 only exists once E10-2 starts proposing start-processing votes, so nothing breaks on merge. Raise an issue so the SV-side guard is tracked against E10-2 rather than lost on this thread. |
…tion [ci] Review follow-up: AmuletRules_StartProcessingExtensionRewardsV2 now takes the RegisteredSynchronizer and requires it to match the report's synchronizer id and operator. An archived registration cannot be fetched, so offboarding closes the reward path structurally, the same way it closes the buy path; matching on operator as well as synchronizer id covers duplicate registrations sharing an id. test_StartProcessing_registrationGate covers the offboarded, wrong-operator, and wrong-synchronizer cases. Signed-off-by: Timothy Wu <tim.wu@chainsafe.io>
Agreed on all of it, including the scope correction — the guard is tracked as ChainSafe/canton-extending-mainnet#61 against E10-2, with one addition from verifying your findings: |
| -- ^ Voted action to change the `AmuletConfig`. Not idempotent. | ||
| | CRARC_StartProcessingRewardsV2 AmuletRules_StartProcessingRewardsV2 | ||
| -- ^ Automated action to start the processing of rewards where the minting allowances were computed off-ledger. | ||
| | CRARC_StartProcessingExtensionRewardsV2 AmuletRules_StartProcessingExtensionRewardsV2 |
There was a problem hiding this comment.
So this works conceptually but this means we have a separate vote for each synchronizer each round. As the number of synchronizers increases that might become tricky to scale.
I wonder if instead we could have the svs commit to a hash of all synchronizers and then have a BatchOfSynchronizers` thing and thereby parallelize the expansion on that.
There was a problem hiding this comment.
Agreed on both the problem and the shape. Sketch of your suggestion as we would build it: per round, SV automation aggregates every synchronizer's report into a single commitment — a BatchOfSynchronizers node whose entries carry (root hash, operator, reported total, issuance rate) per synchronizer. One vote per round creates one root ProcessRewardsV2; expanding the synchronizers node fans out into per-synchronizer subtrees, each endowed with its own expander, rate, and weight budget, and from there operators expand their own trees in parallel exactly as in this PR. The per-synchronizer gates (live registration, reported-vs-purchased) come along as entry fields attested by the same vote. This PR's single-synchronizer flow stays as the primitive the fan-out lands on. And if the unification from the other thread happens, this generalizes all the way: one budget-bounded tree per round in which the global synchronizer is just another entry (expander None, recomputed as today) alongside the extensions.
Proposal: track it as the next Daml rung (issue to follow) rather than growing this PR — the fan-out constructor, the endow-children-per-entry expansion arm, and the round-level start-processing choice layer cleanly on top of what is here, and the aggregation automation rides the SV-side integration work. Should we proceed in this direction @moritzkiefer-da?
… exclusivity [ci] Review follow-ups: - the purchased-traffic bound is now cumulative: ExtensionActivityReport carries the synchronizer's running total of all activity ever reported (stamped by ReportActivity from the state contract), and start-processing checks that running total against the attested purchased total. Rounds that individually fit can no longer together report past what was ever purchased; the per-round check is subsumed. - unweighted internal nodes now also require weightBudget to be None, so the plain and weighted batch shapes are exclusive in both directions. Signed-off-by: Timothy Wu <tim.wu@chainsafe.io>
…nale [ci] Review round 2: - per the reward-distribution decision (proportional to featured apps' activity records), the committed tree must sum to exactly the reported total at every level: both per-level checks tighten from <= to ==, and the tests cover under- as well as over-declaration - the weighted format is no longer extension-specific: BatchOfBatches is deprecated (kept so pre-flip rounds finish processing), the batch checks are framed as weighted-requires-rate, and AmuletRules_StartProcessingRewardsV2 gains appended-Optional issuancePerActivityUnit / weightBudget - None preserves today's behavior and in-flight vote requests decode fine; Some is the hook the Scala switch uses when Scan starts building weighted trees. A new test runs a decentralized-synchronizer round through the weighted format end to end - DedicatedSynchronizerState documents why it is a separate template from RegisteredSynchronizer (requested in review) Signed-off-by: Timothy Wu <tim.wu@chainsafe.io>
…struction sites [ci] The appended-Optional issuancePerActivityUnit / weightBudget on AmuletRules_StartProcessingRewardsV2 ripple into the codegen bindings: the four Scala sites constructing the action (CalculateRewardsTrigger and the traffic-based-rewards integration test) now pass Optional.empty(), which is byte-for-byte the previous behavior. Signed-off-by: Timothy Wu <tim.wu@chainsafe.io>
| round : Round | ||
| activityRootHash : CryptoHash.Hash | ||
| totalActivity : Decimal | ||
| totalActivityReported : Decimal |
There was a problem hiding this comment.
why do we need this on both the extension activity report and the dedicated synchronizer state?
There was a problem hiding this comment.
Three reasons that interlock:
- The state has to hold it because reports do not survive: start-processing consumes the report
it processes, so the running total needs a carrier that persists across rounds - the state is
the accumulatorReportActivityadds to. - The report has to carry it because the gate cannot safely read the state: the vote's action
record pins contract ids at proposal time, and the state's id changes with every report - a
report filed while the vote is open would invalidate it and fail the vote at close (the same
blocking pattern your review found on the offboard vote in PoC: registration lifecycle (offboard vote + reporting-state wiring) #12). - And the stamped copy is the semantically right value: the state's total is "as of now", but the
vote authorizes processing one specific round. If the operator has reported rounds 5-7 while
round 5's vote was pending, the gate should bound round 5's running total, not today's. The
report freezes exactly that number, in the same transaction that created the report.
So: the state is where the total accumulates, the report is the immutable per-round snapshot
that travels to the vote.
Daml PoC of the extension reward integration sketched in the design doc's Section H, stacked on the buy rung (#2). On-ledger pieces only — the SV-side rate computation (tranche split by burn share, the voted per-synchronizer weight, the report timeout) and the operator-side automation stay off-ledger and are not part of this PR.
What this does
Per round, a registered synchronizer's operator commits its activity on the decentralized synchronizer, and later expands that commitment into reward coupons through the existing CIP-104 machinery:
DedicatedSynchronizerState(new, splice-amulet): per-synchronizer reporting state. The operator'sReportActivitychoice records a Merkle root hash over its activity records plus the round total, enforces strictly increasing rounds, accumulates a running total-ever-reported, and creates a per-roundExtensionActivityReport.AmuletRules_StartProcessingExtensionRewardsV2(new choice, vote-dispatched via appendedCRARC_StartProcessingExtensionRewardsV2): consumes a report and creates the rootProcessRewardsV2carrying the report's root hash, the issuance rate computed by DSO automation, the operator as expander, and the report's total as the root weight budget. The vote also attests the synchronizer's total traffic ever purchased, and the choice rejects a report whose lifetime reported activity exceeds it (each report carries the synchronizer's running total, stamped by the report choice). It further requires the report to match a liveRegisteredSynchronizeron both synchronizer id and operator, so offboarding (archiving the registration) closes the reward path the same way it closes the buy path, and a duplicate registration under another operator authorizes nothing.ProcessRewardsV2(extended, per Section H "noting that party on the ProcessRewardsV2 contracts" + "push issuance per activity record into process rewards"): appendedexpander : Optional Party,issuancePerActivityUnit : Optional Decimal, andweightBudget : Optional Decimal; the expander is an observer (it must see the contracts to act) and the controller ofProcessBatchwhen set; leaf expansion mints coupons at activity weight × rate.Nones everywhere (all existing contracts and the gsync path) is byte-for-byte today's behavior — including that the delegate-basedDsoRules_ProcessRewardsV2_ProcessBatchwrapper cannot touch extension contracts.BatchOfWeightedBatches(appendedBatchconstructor) — per review, now the unified format for all reward trees: internal nodes declare each child's subtree weight inside the hashed commitment, and expansion requires exact sums at every level, endowing each child with its declared weight.BatchOfBatchesis deprecated (kept so rounds committed before the switch finish processing), andAmuletRules_StartProcessingRewardsV2gains appended-OptionalissuancePerActivityUnit/weightBudget:Noneis byte-for-byte today's behavior (in-flight vote requests decode fine),Someis the hook the Scala switch uses when Scan starts building weighted trees. A new test runs a decentralized-synchronizer round through the weighted format end to end.Economic bound
The decentralized synchronizer's own trust does not rest on this because every SV recomputes the full tree before confirming the root — expansion is transcription; under the unified format its trees are budget-pinned too, as defense in depth. An extension tree has a single author the SVs cannot recompute; what stands in is a chain of on-ledger checks, each anchored one level up:
A binds the report to reality: the confirming vote carries the purchased total (from
MemberTraffic, which SVs already see), andAmuletRules_StartProcessingExtensionRewardsV2requires the synchronizer's lifetime reported activity — stamped into every report — to fit within it, so rounds that individually fit cannot together report past what was ever purchased. B binds the tree to the report: the root's budget is the reported total, every internal node's children must sum to exactly its budget, and every leaf's allowances to exactly its own (activity records exist only for reward-eligible providers, so the tree accounts for the full total) — no expansion order or tree shape can mint more or less than what was reported. An operator can still distribute weight among its own parties as it pleases; what it cannot do is mint more than the round's attested activity. Processing is also gated on the live registration matching the report (synchronizer id and operator), so an offboarded synchronizer's outstanding reports can never be turned into minting.How it's verified (Daml Script)
TestExtensionRewardReporting(splice-amulet-test): full report → start → expand flow with a two-level weighted tree, coupon amounts = weight × rate; report monotonicity/negative-total/wrong-party negatives; expansion security (tampered pre-image fails the hash check, the DSO party cannot expand extension batches, a leaf cannot be replayed against the wrong hash); economic bound (a report pushing lifetime reported activity past the attested purchased total is rejected, including the multi-round overrun where each round individually fits; internal nodes and leaves must sum exactly to their budgets — over- and under-declaration all fail; a plain unweighted batch cannot smuggle a rate); registration gate (offboarded, wrong-operator, and wrong-synchronizer registrations are all rejected at start-processing).TestExtensionRewardVote(splice-dso-governance-test): the governance path — register via vote, report as operator, start processing via a 4-SV supermajority vote, expand, assert the coupon.Full amulet, wallet, and dso-governance suites pass.
Assumptions (Section H implies but does not pin — flagging for review)
Decimal, reusingMintingAllowanceas the leaf type, with the weight × rate multiplication inNumeric 10at expansion. The committed tree is in activity units because the rate does not exist at report time (it depends on all synchronizers' reports and burns).DedicatedSynchronizerStaterather than onRegisteredSynchronizer(Section H says "possibly in registry contract"); kept separate to leave the registration stable. Production wiring of the state's creation (at registration) follows the report-home decision.CalculateRewardsV2is untouched: the extension flow starts from the report contract; the timeout / count-as-zero behavior is SV-side.providersWithWrongVettingStateremains caller-supplied at expansion; extension expansions pass what the operator knows, with the unhide trigger as the correction path.Tracked in
Implements E10-1 (ChainSafe/canton-extending-mainnet#47); part of Epic E10 (#22). The SV-side integration and operator-side automation are tracked as follow-ups (#57 / #58).