Skip to content

PoC: extension reward reporting + expansion (Daml) - #8

Open
timwu20 wants to merge 7 commits into
feat/dedicated-syncfrom
multi-sync-poc-reward-reporting
Open

PoC: extension reward reporting + expansion (Daml)#8
timwu20 wants to merge 7 commits into
feat/dedicated-syncfrom
multi-sync-poc-reward-reporting

Conversation

@timwu20

@timwu20 timwu20 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

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's ReportActivity choice 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-round ExtensionActivityReport.
  • AmuletRules_StartProcessingExtensionRewardsV2 (new choice, vote-dispatched via appended CRARC_StartProcessingExtensionRewardsV2): consumes a report and creates the root ProcessRewardsV2 carrying 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 live RegisteredSynchronizer on 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"): appended expander : Optional Party, issuancePerActivityUnit : Optional Decimal, and weightBudget : Optional Decimal; the expander is an observer (it must see the contracts to act) and the controller of ProcessBatch when 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-based DsoRules_ProcessRewardsV2_ProcessBatch wrapper cannot touch extension contracts.
  • BatchOfWeightedBatches (appended Batch constructor) — 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. BatchOfBatches is deprecated (kept so rounds committed before the switch finish processing), and AmuletRules_StartProcessingRewardsV2 gains appended-Optional issuancePerActivityUnit / weightBudget: None is byte-for-byte today's behavior (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.

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:

minted  =  Σ committed tree weights  =  round's reported total      lifetime reported  ≤  lifetime purchased
        (B: exact-sum threading)     (B: root budget)               (A: vote-attested cap)

A binds the report to reality: the confirming vote carries the purchased total (from MemberTraffic, which SVs already see), and AmuletRules_StartProcessingExtensionRewardsV2 requires 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)

  1. Activity weights are Decimal, reusing MintingAllowance as the leaf type, with the weight × rate multiplication in Numeric 10 at 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).
  2. The report lives on a new DedicatedSynchronizerState rather than on RegisteredSynchronizer (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.
  3. CalculateRewardsV2 is untouched: the extension flow starts from the report contract; the timeout / count-as-zero behavior is SV-side.
  4. providersWithWrongVettingState remains 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).

timwu20 added 3 commits August 4, 2026 22:47
…) [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>
@timwu20
timwu20 changed the base branch from multi-sync-poc-buy-traffic to feat/dedicated-sync August 5, 2026 02:47
@timwu20
timwu20 force-pushed the multi-sync-poc-reward-reporting branch from 39cfab1 to e002dad Compare August 5, 2026 02:47
@sadiq1971

sadiq1971 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

The SV's reward trigger will pick these up and get stuck

ProcessRewardsV2 is shared with the DSO path, and the SV trigger takes any contract of that template where dryRun is false (DbSvDsoStore.scala:797). So it will claim the extension ones too.

It then can't do anything with them: the batch data belongs to the operator, Scan has no way to serve it (GetRewardAccountingBatchResponse has no case for BatchOfWeightedBatches), and controller fromOptional dso expander means the SV isn't authorized anyway.

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 null

The 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>
@timwu20

timwu20 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

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.

(full comment)

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: RewardMetricsTrigger reads the unfiltered listProcessRewardsV2() for the backlog gauges, so it needs the same filter or extension contracts pollute the DSO-side metrics. Both are in the issue.

@moritzkiefer-da moritzkiefer-da left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice work thanks!

Comment thread daml/splice-amulet/daml/Splice/DecentralizedSynchronizer.daml
Comment thread daml/splice-amulet/daml/Splice/Amulet/RewardAccountingV2.daml Outdated
-- ^ 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sgtm thanks

Comment thread daml/splice-amulet/daml/Splice/AmuletRules.daml Outdated
Comment thread daml/splice-amulet/daml/Splice/Amulet/RewardAccountingV2.daml Outdated
timwu20 added 2 commits August 8, 2026 13:40
… 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>

@moritzkiefer-da moritzkiefer-da left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thx

round : Round
activityRootHash : CryptoHash.Hash
totalActivity : Decimal
totalActivityReported : Decimal

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this on both the extension activity report and the dedicated synchronizer state?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 accumulator ReportActivity adds 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants