CIP-104 Mode A coupon-reassignment automation - #255
Conversation
There was a problem hiding this comment.
Pull request overview
This PR lays the M1+M2 foundation for Mode A CIP-104 coupon reassignment by introducing a new on-ledger governable action to assign RewardCouponV2 beneficiaries and wiring it through DecMan’s Rust proposal plumbing, plus adding DAML tests and design/plan documentation.
Changes:
- Add DAML
AssignRewardBeneficiariesGovernableActionthat exercisesRewardCoupon_AssignBeneficiarieswith an “empty”ExtraArgs. - Add a dedicated DAML test package (
governance-rewards-assign-test) to validate happy-path and key negative cases for coupon assignment. - Add Rust backend support: new
ProposalType::AssignRewardBeneficiaries, boundary validation, action serialization mapping, and serializer shape test.
Reviewed changes
Copilot reviewed 10 out of 12 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/coupon-reassignment/m1-m2-implementation-plan.md | Adds an M1+M2 implementation plan and task breakdown for coupon reassignment foundations. |
| docs/coupon-reassignment/design.md | Adds a Mode A design doc describing the end-to-end automation architecture and constraints. |
| daml/multi-package.yaml | Registers the new DAML test package in the multi-package build. |
| daml/governance-rewards/daml/Governance/Rewards/AssignRewardBeneficiaries.daml | Implements the new governable action wrapping RewardCoupon_AssignBeneficiaries. |
| daml/governance-rewards/daml.yaml | Adds the reward-assignment and token-metadata DARs as data-dependencies for compilation/imports. |
| daml/governance-rewards-assign-test/daml/Governance/Rewards/TestAssignRewardBeneficiaries.daml | Adds DAML scripts covering happy-path and failure modes for assignment. |
| daml/governance-rewards-assign-test/daml/Governance/Rewards/AssignTestUtils.daml | Adds test utilities to create governance and drive propose/confirm/execute in scripts. |
| daml/governance-rewards-assign-test/daml.yaml | Defines the new DAML test package and its DAR dependencies (incl. amulet 0.1.19). |
| crates/decman/src/server/types.rs | Introduces RewardBeneficiary, ProposalType::AssignRewardBeneficiaries, validation, and unit tests. |
| crates/decman/src/server/action_serializer.rs | Adds serialization helper + proposal args mapping + a round-trip shape unit test. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Adds the M3+M4 implementation plan (5 verification passes, 17 findings fixed) to the shared branch so it's part of PR #255, and re-syncs design.md with the cip-104 spec (governance-vs-topology threshold fix, §6.1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the M3+M4 implementation plan (5 verification passes, 17 findings fixed) to the shared branch so it's part of PR #255, and re-syncs design.md with the cip-104 spec (governance-vs-topology threshold fix, §6.1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a163d29 to
d0364fc
Compare
…1.19 (devnet-matched)
Adds AssignRewardBeneficiaries, a GovernableAction that lets governance assign beneficiaries to CIP-104 RewardCouponV2 coupons via the RewardCoupon_AssignBeneficiaries choice, so coupons can be minted before they expire. Ships with its own test package (governance-rewards-assign-test) depending on amulet 0.1.19 (for RewardCouponV2) alongside governance-rewards-v1, since the amulet 0.1.17/0.1.19 version split anticipated in the plan did not in fact conflict. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dation Adds the RewardBeneficiary struct and AssignRewardBeneficiaries variant (primary_coupon + additional_coupons + new_beneficiaries, mirroring the DAML template so "at least one coupon" is a type guarantee) plus a validate() arm enforcing non-empty beneficiaries, <= 20 entries, each percentage in (0.0, 1.0], and an exact sum of 1.0. Backend-only (Task 2 of 4); the serializer arm lands in Task 3.
…Strings, not CantonId primary_coupon/additional_coupons hold ledger contract-ids, not parties. CantonId::parse requires a prefix::hexNamespace shape and rejects bare contract-id strings, so a real coupon cid could never be submitted through the JSON API. Type them as String like every other cid-bearing field in this file; RewardBeneficiary.beneficiary stays CantonId since it is genuinely a party.
Design (L1/L2/L3 actor model; Mode A assign-&-self-mint cranker) and the M1/M2 implementation plan, so reviewers can see the full scope. M3 (auto-confirm engine + cranker) and M4 (devnet IT) land in the same PR once the split-source question in the PR description is settled.
Move docs/reward-cranker → docs/coupon-reassignment and re-copy the swept spec + plan (name change "reward cranker" → "coupon-reassignment automation"). Mirrors the cip-104 source docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the dir rename: the prior commit captured only the move (old wording). This applies the swept content, matching the cip-104 source docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the M3+M4 implementation plan (5 verification passes, 17 findings fixed) to the shared branch so it's part of PR #255, and re-syncs design.md with the cip-104 spec (governance-vs-topology threshold fix, §6.1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebased onto Robert's feat/accept-external-party-setup (#256). Fixups so the combined branch builds green: - governance-rewards -> 0.1.2 (both AcceptExternalPartySetup [#256] and AssignRewardBeneficiaries [M1] in one package); update both test packages' dar refs; ship the 0.1.2 DAR in releases/v1. - GovernanceSection.tsx: add a default case to the propose switch so the automation-only proposal types (assign_reward_beneficiaries, and the coming set_reward_split) — which have no UI form by design — don't leave `proposal` possibly-unassigned (TS2454). Surfaced once gen-types emits those variants into the TS union. Verified: cargo test -p decman green; frontend tsc clean; dpm build --all clean; all reward DAML scripts (mine + Robert's) pass at 0.1.2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 1 of the Mode A M3+M4 plan. On-ledger governance-configured reward split (keyless singleton, replace-by-cid) + the GovernableAction that sets/replaces it, with execute-time guards (non-empty, <=20, exact sum 1.0). Tests: creates, replaces (singleton), empty-rejected. Refresh the 0.1.2 DAR. All reward DAML scripts (mine + Robert's) pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 2 of the Mode A M3+M4 plan. New ProposalType::SetRewardSplit
{ new_beneficiaries, prior_config } reusing the existing exact-Decimal
validate_reward_beneficiaries helper; serializer arm (governanceParty,
proposer, priorConfig, beneficiaries) with a make_optional_contract_id
helper. Validation + round-trip unit tests. fmt/clippy/tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…_governance_rules Task 3 of the Mode A M3+M4 plan. Behavior-preserving: propose_action now calls submit_proposal; the active-GovernanceRules resolution is a reusable helper. Widen execute_confirm_action/get_party_credentials/packages() to pub(crate) for the reward-automation module. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 4 of the Mode A M3+M4 plan. New reward_automation module: shared active_created_records (GetActiveContracts, decoded via the fetch_proposal_infos pattern), OnLedgerSplitSource (defensive singleton RewardSplitConfig read), and unassigned_coupons (RewardCoupon interface view). parse_split_record unit-tested; I/O verified later by the devnet IT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The codebase deliberately avoids async-trait and there is one split source today, so replace the single-impl SplitSource trait + OnLedgerSplitSource with a plain pub(crate) async fn effective_split (same single swap point for a future shared reward-config template). Removes the async-trait direct dependency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tasks 5-6 of the Mode A M3+M4 plan. split_matches (exact-Decimal set equality) + default-deny is_confirmable; parse_assign_record + read_pending_assign to read a pending AssignRewardBeneficiaries proposal's coupons + split. Pure logic unit-tested; the read is verified by the devnet IT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tasks 7-8 of the Mode A M3+M4 plan. select_batch (TTL-watermark + minting-margin + cap) + run_proposer_once (dedupe via covered coupons, propose AssignRewardBeneficiaries); already_confirmed_by + run_confirmer_once (validate each pending proposal against the on-ledger split, auto-confirm via CoreDomain). Pure logic unit-tested; the I/O is verified by the devnet IT. Also re-exports submit_proposal + execute_confirm_action (pub(crate), Task 3) from handlers::mod so they are reachable through the private governance submodule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 9 of the Mode A M3+M4 plan. run_reward_automation_loop + run_once_for_party tie the reader/proposer/confirmer together, fetching governance confirmations once per tick and sharing them for dedupe + confirm. Registered in start_server by cloning the existing web::Data<AppState> (shared state, not a fresh AppState). New NodeConfig.reward_automation_interval_secs (default 300s); enablement is on-ledger (RewardSplitConfig presence). Re-export the governance helpers; drop the now-unnecessary dead_code allows. clippy/fmt/tests/tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| // ============================================================================ | ||
| // Shared decoded ACS read | ||
| // ============================================================================ | ||
|
|
||
| /// A single decoded `GetActiveContracts` read. | ||
| /// | ||
| /// For `interface_view = false` this uses a `TemplateFilter` (or, under | ||
| /// `test_mode`, a `WildcardFilter` with in-memory template matching, since mock | ||
| /// auth lacks `TemplateFilter` permission) and returns each created event's | ||
| /// `create_arguments` `Record`. For `interface_view = true` it uses an | ||
| /// `InterfaceFilter { include_interface_view: true }` and returns the decoded | ||
| /// interface-view `Record`. Field labels are populated because the request is | ||
| /// `verbose`. | ||
| /// | ||
| /// Modeled on `queries::fetch_proposal_infos`. | ||
| // The full filter descriptor (package/module/entity + template-vs-interface) is | ||
| // intentionally passed positionally so this stays the single shared read for | ||
| // every reward-automation query. | ||
| #[allow(clippy::too_many_arguments)] | ||
| pub(crate) async fn active_created_records( | ||
| config: &NodeConfig, | ||
| party_id: &CantonId, | ||
| token: Option<String>, | ||
| test_mode: bool, | ||
| package_id: &str, | ||
| module: &str, | ||
| entity: &str, | ||
| interface_view: bool, | ||
| ) -> anyhow::Result<Vec<(String, Record)>> { | ||
| let mut state_client = utils::create_state_client(config, token).await?; | ||
|
|
||
| let ledger_end = state_client | ||
| .get_ledger_end(tonic::Request::new(GetLedgerEndRequest {})) | ||
| .await? | ||
| .into_inner() | ||
| .offset; | ||
|
|
||
| let identifier_filter = if interface_view { | ||
| cumulative_filter::IdentifierFilter::InterfaceFilter(InterfaceFilter { | ||
| interface_id: Some(Identifier { | ||
| package_id: package_id.to_string(), | ||
| module_name: module.to_string(), | ||
| entity_name: entity.to_string(), | ||
| }), | ||
| include_interface_view: true, | ||
| include_created_event_blob: false, | ||
| }) | ||
| } else if test_mode { | ||
| cumulative_filter::IdentifierFilter::WildcardFilter(WildcardFilter { | ||
| include_created_event_blob: false, | ||
| }) | ||
| } else { | ||
| cumulative_filter::IdentifierFilter::TemplateFilter(TemplateFilter { | ||
| template_id: Some(Identifier { | ||
| package_id: package_id.to_string(), | ||
| module_name: module.to_string(), | ||
| entity_name: entity.to_string(), | ||
| }), | ||
| include_created_event_blob: false, | ||
| }) | ||
| }; | ||
|
|
||
| let mut filters_by_party = HashMap::new(); | ||
| filters_by_party.insert( | ||
| party_id.to_string(), | ||
| Filters { | ||
| cumulative: vec![CumulativeFilter { | ||
| identifier_filter: Some(identifier_filter), | ||
| }], | ||
| }, | ||
| ); | ||
|
|
||
| let acs_request = GetActiveContractsRequest { | ||
| active_at_offset: ledger_end, | ||
| event_format: Some(EventFormat { | ||
| filters_by_party, | ||
| filters_for_any_party: None, | ||
| verbose: true, | ||
| }), | ||
| }; | ||
|
|
||
| let mut stream = state_client | ||
| .get_active_contracts(tonic::Request::new(acs_request)) | ||
| .await? | ||
| .into_inner(); | ||
|
|
||
| let mut out = Vec::new(); | ||
| while let Some(response) = stream.message().await? { | ||
| if let Some(ContractEntry::ActiveContract(active)) = response.contract_entry | ||
| && let Some(created) = active.created_event | ||
| { | ||
| if interface_view { | ||
| let Some(view) = created.interface_views.iter().find(|v| { | ||
| v.interface_id | ||
| .as_ref() | ||
| .is_some_and(|id| id.module_name == module && id.entity_name == entity) | ||
| }) else { | ||
| continue; | ||
| }; | ||
| if let Some(rec) = view.view_value.clone() { | ||
| out.push((created.contract_id.clone(), rec)); | ||
| } | ||
| } else { | ||
| // Wildcard (test mode) returns every template; keep only the | ||
| // requested one. Match on module/entity — the package alias | ||
| // (`#…`) is resolved to a concrete hash on the wire. | ||
| if test_mode | ||
| && !created | ||
| .template_id | ||
| .as_ref() | ||
| .is_some_and(|t| t.module_name == module && t.entity_name == entity) | ||
| { | ||
| continue; | ||
| } | ||
| if let Some(rec) = created.create_arguments.clone() { | ||
| out.push((created.contract_id.clone(), rec)); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Ok(out) | ||
| } |
There was a problem hiding this comment.
This logic must already exist somewhere in this repo, or canton-lib. If not, it should be in a utils.rs file?
I am also a bit skeptical about test mode lacking template filtering? Is that true?
There was a problem hiding this comment.
Two separate things here.
The record parsing — agreed, and it is filed as #283: the same field traversal is inlined about 60 times across four modules, and canton-lib has no helper for it. Left out of this PR because the extraction touches all of those call sites and would bury the feature diff. If you would rather it land here, say so and I will do it.
Test-mode template filtering — it does filter, and the filter is the if test_mode && !created.template_id... block right below this line. What is missing is the server-side filter: mock auth rejects a TemplateFilter, so test mode sends a WildcardFilter and discards non-matching templates in memory. Same result, more bytes on the wire. Tracked as #272.
Worth noting the wildcard read is also why active_delegation filters on the decparty field — under test mode the response genuinely contains every template on the participant.
| /// The DSO whose coupons this delegation may assign. Any party can mint a | ||
| /// `RewardCouponV2` naming itself `dso` and this decparty as `provider`, so | ||
| /// a coupon from any other DSO must never enter a batch: as the batch's | ||
| /// primary it makes splice reject every genuine coupon alongside it, and a | ||
| /// failed chunk ends the tick. |
| /// Return the number of elements in a `List` field. | ||
| fn field_list_len(rec: &Record, label: &str) -> anyhow::Result<usize> { | ||
| match record_field(rec, label) { | ||
| Some(value::Sum::List(l)) => Ok(l.elements.len()), | ||
| _ => Err(anyhow!("field `{label}`: expected a List value")), | ||
| } | ||
| } | ||
|
|
||
| /// Read a list-of-`Party` field, parsing each element into a [`CantonId`]. | ||
| /// Mirrors `field_contract_id_list`, decoding each element the same way | ||
| /// `field_party_id` decodes a single `Party` value. | ||
| fn field_party_list(rec: &Record, label: &str) -> anyhow::Result<Vec<CantonId>> { | ||
| let list = match record_field(rec, label) { | ||
| Some(value::Sum::List(l)) => l, | ||
| _ => return Err(anyhow!("field `{label}`: expected a List value")), | ||
| }; | ||
| list.elements | ||
| .iter() | ||
| .map(|elem| match elem.sum.as_ref() { | ||
| Some(value::Sum::Party(p)) => p | ||
| .parse::<CantonId>() | ||
| .with_context(|| format!("field `{label}`: invalid party id `{p}`")), | ||
| _ => Err(anyhow!("field `{label}`: element is not a Party")), | ||
| }) | ||
| .collect() | ||
| } |
There was a problem hiding this comment.
Should be in a utils file, what do you think. Along with all the generic record parsing stuff. Shared across the whole repo.
There was a problem hiding this comment.
Same as above — filed as #283, which covers the generic record-parsing helpers repo-wide rather than just the ones this PR added. I would rather move all ~60 call sites at once than leave two idioms in the tree.
|
|
||
| /// The active `CouponReassignmentDelegation` for a decparty, read from the | ||
| /// ledger, or `None` when there is none (automation not enabled for that | ||
| /// decparty). Defends the keyless-singleton invariant: more than one active |
There was a problem hiding this comment.
what is the keyless-singleton invariant? :D
There was a problem hiding this comment.
It was the rule that a decparty has at most one active delegation — "keyless" because Canton has no cross-participant contract-key uniqueness to enforce it with.
Moot now: 1402db7 takes the newest instead of refusing, so the phrase is gone and the doc comment says plainly what the function does and why.
| let mut mine: Vec<(String, Record)> = records | ||
| .into_iter() | ||
| .filter(|(_, rec)| field_party_id(rec, "decparty").ok().as_ref() == Some(decparty)) | ||
| .collect(); | ||
|
|
||
| match mine.len() { | ||
| 0 => Ok(None), | ||
| 1 => { | ||
| let (cid, rec) = mine.remove(0); | ||
| Ok(Some(parse_delegation_record(&cid, &rec)?)) | ||
| } | ||
| n => { | ||
| tracing::warn!(%decparty, count = n, "ambiguous CouponReassignmentDelegation — refusing"); | ||
| Err(anyhow!( | ||
| "ambiguous CouponReassignmentDelegation: {n} active — refusing" | ||
| )) | ||
| } | ||
| } |
There was a problem hiding this comment.
we could just grab the latest delegation contract, instead of this complicated logic
There was a problem hiding this comment.
Done in 1402db7. active_delegation now takes the newest by created-event offset instead of refusing.
I had drafted a pushback on this and Gyorgy overruled it, correctly. Refusing stops assignment entirely and every coupon expires, which is worse than acting on the split the most recent vote produced.
Two details worth knowing. "Newest" is the CreatedEvent.offset from the ledger, not ACS response order, so every node picks the same contract without coordinating. And the selection is now a pure newest_delegation_for with three tests: newest wins with the newest fed first, another decparty's delegation is ignored even when it is the newest contract in the response, and empty input yields None.
Design §11/§12 updated. The residual risk is unchanged and still documented: a superseded delegation stays exercisable by a direct ledger submit until it is revoked.
| let mut out = Vec::new(); | ||
| for (cid, rec) in records { | ||
| if let Some(coupon) = parse_unassigned_coupon(&cid, &rec, decparty, dso)? { | ||
| out.push(coupon); | ||
| } | ||
| } |
There was a problem hiding this comment.
couldn't this be a records.map()?
There was a problem hiding this comment.
Yes. Done in 1402db7:
records
.iter()
.filter_map(|(cid, _, rec)| parse_unassigned_coupon(cid, rec, decparty, dso).transpose())
.collect()parse_unassigned_coupon returns Result<Option<CouponInfo>>, so Result::transpose turns it into Option<Result<..>> and collect into Result<Vec<_>> keeps the error propagation the loop had.
| // `dso` is the only signatory of `RewardCouponV2`, so any party can mint one | ||
| // naming itself `dso` and this decparty as `provider`, and it lands in the | ||
| // decparty's ACS. Such a coupon is a genuine RewardCouponV2 — it is simply | ||
| // not ours to assign, and letting it into a batch is a denial of service: | ||
| // sorted most-urgent-first it becomes the primary, splice fetches every | ||
| // other coupon with the primary's `dso`, the whole chunk is rejected, and | ||
| // the tick ends having assigned nothing. |
There was a problem hiding this comment.
the same unncessary comment again. I think it's obvious we check the dso on the contract!
There was a problem hiding this comment.
Fair — deleted in 1402db7. The check reads for itself; the rationale lives in design §12 and in the DAML choice.
| /// The exposure this accepts: a coupon that is genuinely un-exerciseable — a | ||
| /// package-version mismatch being the realistic case — heads the deterministic | ||
| /// most-urgent-first order on every tick and stalls assignment indefinitely. | ||
| /// Skipping it would not repair that; it would lose the coupon anyway while | ||
| /// hiding the cause. So the failing coupon id is logged for alerting to pick up | ||
| /// and a human to diagnose. |
There was a problem hiding this comment.
Not great... Basically unacceptable, without giant PagerDuty warnings, and a runbook on how to fix such a stuck process. I think we can come up with a way to retry batches, while ignoring the misbehaving coupon
There was a problem hiding this comment.
Agreed, and we reached the same conclusion independently — this is filed as #277 with the fix fully specced. Two things there worth your eyes, because the obvious implementation is wrong.
Skip-the-misbehaving-coupon is right, but it must not be unconditional. Every assign failure devnet has ever produced is contention — another assigner committed first — and skipping on those grinds through the batch to learn what one re-read gives free. So the fix classifies first: transient ends the tick as today, deterministic isolates the coupon and continues.
The gRPC status code cannot make that distinction. Measured on devnet, benign contention arrives under three different codes:
| Canton error id | gRPC code |
|---|---|
LOCAL_VERDICT_LOCKED_CONTRACTS |
ABORTED |
LOCAL_VERDICT_INACTIVE_CONTRACTS |
NOT_FOUND |
UNKNOWN_CONTRACT_SYNCHRONIZERS |
FAILED_PRECONDITION |
A classifier keyed on the code would call that third row deterministic and quarantine a coupon another node had just assigned correctly. So the transient class is an allowlist of Canton error ids, with anything unrecognized treated as transient. Canton's own retryability flag is also wrong for us — it marks 9 and 11 non-retryable because resubmitting cannot work, but we re-read, and a fresh read fixes both.
Verified that downcast_ref::<tonic::Status>() reaches the id, and that it survives added .context() layers.
One correction to the isolation step: the bad coupon can sit mid-chunk, not just at the head — splice fetches and validates every additionalCoupon. So dropping the primary and retrying advances one coupon per tick. It needs a bisect.
Still open: no deterministic rejection has ever been observed on devnet. Every failure on record is contention or a network fault. So the localnet IT that seeds a coupon the read admits and the exercise rejects is required — it is the only evidence that branch will have.
On your merge condition: whether this lands in this PR or as the follow-up #277 already scopes is Gyorgy's call and is open. Worth noting the two options are not equal in size — #277 is specced down to the test list, while the alerting alternative (#278) is the larger and less-defined piece of work.
| /// The DSO whose coupons the delegation may assign. Fixed by this vote: | ||
| /// `dso` is `RewardCouponV2`'s only signatory, so any party can mint one | ||
| /// naming itself `dso` and the decparty as `provider`, and the | ||
| /// automation must be able to tell those apart from the real ones. |
| let zero = "0" | ||
| .parse::<DamlDecimal>() | ||
| .expect("'0' is a valid DamlDecimal"); | ||
| let one: DamlDecimal = "1".parse().expect("'1' is a valid DamlDecimal"); |
There was a problem hiding this comment.
Is this necessary? We usually don't let expects stay in production code. This seems a bit silly anyway.
There was a problem hiding this comment.
Removed in 1402db7. DamlDecimal::ZERO already exists as a const, so the zero parse was pure noise; the one comparison now uses DamlDecimal::parse("1").map_err(|e| e.to_string())?, which fits the function's existing Result<(), String> return.
Not adding rust_decimal as a direct dependency for Decimal::ONE — there is a comment elsewhere in the file explaining that avoidance deliberately.
Note there is a second instance of the same pattern in validate_positive_amount (line ~528). It predates this PR, so I left it rather than widen the diff. Say if you want it swept too.
| /// Output contracts one Delegation_Assign may create, bounding the | ||
| /// coupons per transaction. Raise stepwise to find the ledger's real | ||
| /// ceiling; set too high, assigns fail and nothing is assigned. | ||
| /// Defaults to 100. | ||
| #[arg(long, env = "DECPM_REWARD_MAX_CREATES")] | ||
| reward_max_creates: Option<usize>, |
There was a problem hiding this comment.
this sounds like we should figure out how big such a resulting coupon is, and could fix this value better. But the 100 default sounds good
There was a problem hiding this comment.
Agreed on both halves, and the ceiling now has a number attached.
Devnet has committed 36 coupons x 2 beneficiaries = 72 output creates in a single transaction (2026-08-06 15:28 UTC), across 33 assigns, with no size rejection ever observed. So the real ceiling is known only to be above 72, and the 100 default sits just above the largest thing we have proven.
Tracked as problem 2 in #277, which is where the measurement belongs.
| "governance-core-v1-0.1.0.dar", | ||
| "governance-token-custody-v1-0.1.0.dar", | ||
| "governance-utility-onboarding-v1-0.1.0.dar", | ||
| "governance-rewards-automation-v1-rc1-0.1.0.dar", |
There was a problem hiding this comment.
yeah lets switch to v1 before we merge this
There was a problem hiding this comment.
but only after we have made and tested any changes to the DAML ofc.
There was a problem hiding this comment.
Renamed in 1402db7, but to -rc2, not -v1 — and your own caveat in the next comment is the reason.
The extraArgs change above alters the Delegation_Assign signature, so the DAML model is still moving. Gyorgy's call: -v1 is the name we take when we are certain there are no further model changes, and we are not there yet. Adding a required field to a choice argument is not an SCU-compatible upgrade, so the vetted rc1 cannot be upgraded in place either way — the rename is forced, only the target name was a choice.
So this lands as governance-rewards-automation-v1-rc2 0.1.0, and -v1 is the last step before merge.
There was a problem hiding this comment.
Right, and that ordering is what we followed. The DAML changed first (extraArgs), it is built and tested — 17 assign-test scripts pass, governance-core-test still passes 56 after the new dependency — and only then did the package get renamed.
It needs a devnet round-trip before -v1: upload and vet the rc2 DAR, roll nodes 1/2/4, and one governance vote to create the delegation under the new package. Devnet is currently healthy on rc1 with zero coupons lost over four days, so there is no pressure to rush it.
| pub fn active_contracts_request(party: &str, template_id: &str, offset: i64) -> Value { | ||
| json!({ | ||
| "eventFormat": { | ||
| "filtersByParty": { | ||
| party: { | ||
| "cumulative": [{ | ||
| "identifierFilter": { | ||
| "TemplateFilter": { | ||
| "value": { | ||
| "templateId": template_id, | ||
| "includeCreatedEventBlob": false, | ||
| } | ||
| } | ||
| } | ||
| }] | ||
| } | ||
| }, | ||
| "verbose": false | ||
| }, | ||
| "verbose": false, | ||
| "activeAtOffset": offset, | ||
| }) | ||
| } |
There was a problem hiding this comment.
doesn't this logic exist in many places already?
There was a problem hiding this comment.
Checked — this one does not exist elsewhere. The JSON pointer /contractEntry/JsActiveContract/createdEvent/createArgument plus the coupon field extraction appears only here, and this file (tests/common/ledger_api.rs) is already the shared test-helper module. The other as_array() hits in tests/ are plain serde_json, not the same logic.
Left as is. If it does get a second caller, this is the right place for it.
scolear
left a comment
There was a problem hiding this comment.
Going to be very nice, but I did leave a lot of comments.
Delegation_Assign now takes extraArgs and passes it to splice instead of hardcoding an empty one. splice ignores it for RewardCouponV2, so behavior is unchanged, but a later coupon version that needs a choice context no longer forces a DAML change. The Rust side reuses action_serializer's existing make_extra_args rather than building its own. active_delegation takes the newest delegation by created-event offset when several are live, instead of refusing. Refusing stopped assignment entirely and lost every coupon to expiry, which is worse than acting on the split the most recent vote produced. The offset comes from the ledger, so every node picks the same contract. The selection is now a pure function with tests covering read order and another decparty's delegation. The package is renamed to -rc2. The choice signature change is not an SCU-compatible upgrade, so the vetted rc1 cannot be upgraded in place. The name stays a release candidate because the DAML model is still moving; -v1 is for the merge that freezes it. Also: drop the "spec §" cross-references, which pointed at design.md under a name no reader could resolve, and rename them to "design §"; trim comments that restated the code; reuse governance-core-test's submitConfirmations in the assign tests; and replace two infallible parse().expect() calls in validate_reward_beneficiaries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n-reassignment-automation
main moved to canton-proto-rs 651ea52 (#307), which adds two required fields: stream_continuation_token on GetActiveContractsRequest and taps_max_passes on Commands. This branch still compiled locally against the older pinned rev, but CI builds the PR merge ref, so it took main's dependency and this branch's initializers and failed. Both are set to None, matching every other call site main updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
schronck
left a comment
There was a problem hiding this comment.
Read through it. Most of what I'd flag is already in scolear's pass, so just the stuff I didn't see covered.
The reader and the DAML disagree about what a coupon is
unassigned_coupons reads the RewardCoupon interface and filters on view fields only (provider, dso, beneficiary). Delegation_Assign then does fetch (fromInterfaceContractId @RewardCouponV2 ...), so it only accepts the concrete V2.
Anything that implements the interface, sits in the decparty's ACS, and has provider/dso/beneficiary looking right will pass the Rust filter and fail the DAML. Selection sorts by expiresAt ascending, so it ends up primary on every tick, the first chunk fails, drain breaks, assigned=0. Forever.
No attacker needed. The module doc already says "on devnet the concrete implementer is RewardCouponV2", which is the assumption doing the work. Second implementer ships, or a version skew, and the engine wedges. The adversarial version works too, observers don't authorize creates so anyone with a vetted package can plant one.
Same stall scolear flagged, but with a trigger you can actually reach, and I don't think the dso filter closes it. That filter only catches genuine V2 coupons from a foreign DSO. A non-V2 implementer walks straight past it, and there the thing rejecting the batch is our own guard, not splice. So the persona-gate note saying quarantine was unnecessary "once the dso filter keeps such coupons out of the batch entirely" doesn't hold for that case.
Fix looks small: active_created_records already has created.template_id and throws it away in the interface branch (~line 205). Keep it, drop anything that isn't Splice.Amulet:RewardCouponV2. Then the reader admits exactly what the DAML accepts and the whole class goes away without touching the drain rule.
additionalCoupons aren't guarded
Both guards in Delegation_Assign apply to primaryCoupon only. additionalCoupons go straight into RewardCoupon_AssignBeneficiaries. The comment says splice fetches them with the primary's dso, which is probably true, but nothing here pins it. That's the hole guard 1 was added to close, left open in the other argument position. A forA_ doing the same fetch + dso assert costs fetches splice performs anyway. If you'd rather not add it, at least put in a DAML negative so an upstream change can't quietly remove the protection.
Smaller
409 guard. propose_action awaits active_delegation before it looks at the proposal type, so every propose of every type now pays a GetLedgerEnd + GetActiveContracts (wildcard ACS read under test_mode). creates_second_delegation can only be true for one variant, so match first and hit the ledger second. Also if let Ok(active) drops the error with no log, and active_delegation errors precisely when there's more than one delegation live. So in the exact state the guard exists for, a third Setup with prior_delegation: null goes through.
Panics kill the loop. It's tokio::spawn with the handle dropped. An Err gets logged, a panic just ends the task and the process carries on serving HTTP with reward automation dead. Two panic sites on the path: format!("Bearer {token}").parse().unwrap() and the split_first().expect(...). The expect is unreachable, the unwrap isn't (non-ASCII in the token). With #278 still a follow-up nothing would tell you.
Package rename. default_package_config flips governance_rewards to the new name and the whole package moved, Mode B included. dars_dir() is the runtime data dir so the DAR doesn't ship in the image. New DAR vetted on every participant first, then roll the binary, otherwise Mode B breaks on start. Worth spelling out in the body, it currently only covers the stale delegation.
rc1. Agree with scolear, and it's worse than cosmetic. SCU keys on package name, so once it's vetted anywhere real the only way to drop the suffix is another rename-and-migrate, which is the thing this PR is doing to get off governance-rewards-v1. Do it now, not after.
Nits. provider/amount on CouponInfo are #[allow(dead_code)]. The two extra .dar blobs in releases/v1 aren't referenced by anything. Localnet IT sets dso = p1_member = assigner_a, so the dso guard never runs e2e against a distinct DSO.
The delegation design itself looks right to me and the drain tests are good. The 3 day devnet outage is better evidence for abort-only than any test would be. It's mainly the first two I'd want closed before this goes in.
A coupon the ledger will never accept used to end every tick. Sorted most-urgent-first it heads the batch, the first chunk fails, the drain stops, and nothing is assigned until it ages out — up to its full 36h TTL, taking every healthy coupon with it. drain_assignable now handles a failure by cause. A stale view still ends the tick, because only a fresh read fixes it and that is the common case: on a 3-assigner deployment two nodes lose every round. A rejected command is bisected instead, so the one coupon at fault is found wherever it sits in the chunk, logged at ERROR, and skipped while the rest are paid. The two are told apart by the Canton error id, never the gRPC code. Devnet shows benign contention arriving as ABORTED, NOT_FOUND and FAILED_PRECONDITION, so a code-based rule would bisect a chunk another node had just assigned correctly. Canton's retryability flag answers a different question: it means resubmission cannot work, while this automation re-reads, which does fix those. The skip lasts one tick. Nothing is quarantined across ticks, so the drain stays stateless, a misclassification costs one tick rather than stranding a healthy coupon until restart, and a genuinely bad coupon keeps producing an ERROR for alerting. Closes the reachable trigger too: unassigned_coupons read the RewardCoupon interface and admitted every implementation, while Delegation_Assign fetches the concrete RewardCouponV2. A second implementer or a package skew would have passed the reader and failed the exercise, with no attacker involved. The read now admits only Splice.Amulet:RewardCouponV2, so the reader matches what the DAML accepts. Delegation_Assign also checks the dso of every coupon rather than the primary alone. splice enforces this today — verified, the new negative test passes with our check narrowed — so this makes the property ours instead of borrowed, and the test guards an upstream change. Also from review: propose_action matches the proposal shape before reading the ACS, so other proposals stop paying for it, and it no longer swallows the read error in the state the guard exists for; the header parse no longer panics a detached task; CouponInfo drops two dead fields; and two superseded DARs this branch added are removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks @schronck — the first finding is the one that mattered, and it refutes something this PR asserted in writing. All of it is addressed in 9f9aa28 except the two noted at the end. The reader and the DAML disagreed — fixed, and you were right that the dso filter did not close itFixed as you described: Your point about the persona-gate note is correct and I am the one who wrote it. "Quarantine was unnecessary once the dso filter keeps such coupons out of the batch entirely" only holds for a genuine V2 from a foreign DSO. A non-V2 implementer walks straight past that filter, and it is our own typed additionalCoupons — guarded, and the test does not discriminate
Worth reporting precisely, because it changes what the test is worth. I added So the test pins the outcome, not our guard, and its comment now says so. Its job is the upstream change you named: if splice stops enforcing it, the test holds the property and the per-coupon check makes it ours rather than borrowed. (Checking discrimination at all is a habit from an earlier round on this PR, where a DAML test passed with the fix removed for exactly this reason.) The drainRewritten in 9f9aa28, closing #277. scolear asked for the same thing. It classifies by Canton error id rather than gRPC code — devnet contention arrives as Smaller409 guard. Both halves fixed. Your sub-point about Panics. The reachable one is fixed — the header parse returns an error instead of Package rename. Gyorgy's call was Nits. Verified locally: 335 Rust unit tests, clippy clean, 18 assign-test DAML scripts, 56 core-test scripts. |
On rc2 vs v1 — @schronck raises a cost I had not weighedHis argument: SCU keys on package name, so every rc bump costs the same rename-and-migrate as going to v1 — which is precisely the migration this PR performs to escape The counter-argument, and the reason Both are real. The question that settles it is whether we expect any further non-SCU-compatible DAML change before mainnet, and that is @gyorgybalazsi's call, not something to resolve by whoever comments last. What is not in dispute: this must be Deploy ordering — @schronck is right that the PR body understates itHe notes The order is: vet the new DAR on every participant first, then roll the binary. Rolling first leaves the node resolving a package nobody has vetted, and it breaks Mode B on start, not just reassignment. I will put that in the PR body rather than leave it in a comment thread. |
A vote whose percentages look right is rejected at execute for two reasons that were written down nowhere a proposer looks. The sum is compared as exact Decimal, so an even 3-way split does not exist and the last entry has to be balanced by hand. And nothing is implicitly left to the decparty: to keep a remainder it must be listed as its own beneficiary. The review asked for this in the proposer UI. There is no proposer UI — setup_coupon_reassignment_delegation is automation-only and GovernanceSection.tsx rejects it by design — so the facts go where a proposer actually is: the validate_reward_beneficiaries error, which now says how to fix it rather than only what is wrong, the OpenAPI schema doc for new_beneficiaries, and design §8. Also narrow submit_proposal, execute_confirm_action and SubmitProposalError back to private. They were widened to pub(crate) for the auto-confirm engine, which the delegation model deleted; nothing outside governance.rs has called them since. get_party_credentials and packages stay pub(crate) — the reward automation does use those. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closing out the review-body notes (@scolear)The inline comments were answered earlier; these are the five from your review body, which I had not gone through one by one. Pushed as 2d57b15. The split must sum to exactly 1.0 — done, but not where you asked. You asked for it in the proposer UI. There is no proposer UI for this action:
TLS. No code change; it is a pre-mainnet action. The caveat in the PR body now says "exercise it on a test network before mainnet" rather than only noting it is unexercised.
Single-delegation rule. You marked it acceptable. One thing changed under it anyway: Ties in expiry order. Correct, and it stopped mattering: the stall is fixed by cause now, not by order, so reproducibility of the ordering is no longer load-bearing. The head-of-line stall is fixed rather than gated on #278 — 9f9aa28, closing #277. Also in this push: the two |
The Setup and Revoke actions had no form and were rejected by the automation-only branch, so the only way to vote one in was a hand-built JSON payload. That is where the split rules bite: the percentages are compared as exact Decimal, so an even 3-way split does not exist, and nothing is implicitly left to the governance party. Both are discovered at execute, after the confirmations are spent. The form states both rules before the proposer types anything, and validates the split as scaled integers rather than floats — summing 0.3333333333 three times as f64 lands within 1e-9 of 1.0, so a tolerance check would pass a split the ledger rejects. It also reduces a failure the propose boundary cannot catch: a typo'd beneficiary party bricks the delegation for its lifetime, and typing parties into rows beats assembling them into JSON by hand. Assigners get their own rows with a uniqueness check, and the help text says what the field means for liveness (any one of them suffices) and for co-hosting (an assigner's participant must host this party). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Correction to my last reply, @scolear — I read your ask too narrowly and Gyorgy pushed back on it. I said "there is no proposer UI, so the facts go in the API error instead." But you almost certainly assumed a form exists, the way every other governance action has one, and your goal was that a human learns the rules before submitting. Relocating them to an API error does not serve that goal. So the form now exists — 68c81fa. Setup Coupon Reassignment Delegation and Revoke Coupon Reassignment Delegation are in the Rewards section of the action menu. The Setup form leads with both rules:
One detail worth flagging, because the obvious implementation is wrong. The existing Beyond the split rules, the form also reduces a failure the propose boundary cannot catch at all: a typo'd beneficiary party bricks the delegation for its lifetime (filed as won't-fix earlier, because propose cannot verify party existence). Typing parties into labelled rows is a smaller target than assembling them into JSON by hand. Assigners get their own rows with a uniqueness check, and the help text says what the field actually controls — any one assigner suffices for liveness, and an assigner's participant must also host this governance party or it cannot read the coupons. Verified: |
The delegation's percentages are compared as exact Decimal, so an even 3-way split cannot be written down: 0.3333333333 three times is 0.9999999999 and the vote fails at execute, after the confirmations are spent. The form previously stated that rule and left the proposer to balance the last entry by hand. It now takes whole-number weights and derives the decimals. Equal thirds is 1/1/1. Each row's exact share is shown as you type, so the proposer still sees and owns the numbers that get baked into the delegation. The rounding remainder goes to the largest weight, ties by row order, and the row that absorbs it is marked. The rule is deterministic on purpose: a confirmer has to be able to reproduce the split from the weights alone, so "whichever row happened to be picked" would not do. Distortion is at most (n-1) x 1e-10. Weights are BigInt, so this constrains nothing — entering 3333333333/3333333333/3333333334 reproduces exactly those decimals if a particular row should carry the extra unit. One case the ledger rejects and the form now catches first: a weight small enough against the total for its share to floor to zero. Daml requires every percentage in (0,1], and the form names the offending row rather than letting the vote fail at execute. Verified the arithmetic across equal thirds, 80/20, 2:1, 50/30/20, seven equal, twenty equal and the floor-to-zero case; every one sums to exactly 1.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extraction came from the auto-confirm engine design, where a background loop had to propose without an HTTP request. The delegation pivot deleted that engine and the extraction stayed. It has one production caller, the handler itself, and the reward automation never touches it — so the honest answer to "why is this in this PR?" is that it should not be. governance.rs is now main's version plus only what the feature needs: the propose-time guard against a second CouponReassignmentDelegation, its test, and pub(crate) on get_party_credentials and packages, which the automation imports. The diff against main drops from 412 insertions to 114. This also reverts POST /governance/propose from 503 back to 400 for an unconfigured governance package. That change was never requested. It arrived while repairing the collapse-to-500 that the extraction itself caused, and Copilot's comment had asked to *preserve* the previous status-code semantics, which is 400. With the extraction gone, the bug it caused goes too, and the correction becomes an unrelated improvement with no reason to ride along. Filed separately. The 503 that remains is a different one: the singleton guard refusing a proposal when it cannot read the ledger to check. Two tests are gone with the code they covered. They guarded submit_proposal's status mapping, which no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What this PR does
Old behavior
A decentralized party (decparty — e.g.
cbtc-network) earns CIP-104 app rewards asRewardCouponV2coupons, but has no wallet: it can act only through m-of-n threshold governance. Assigning each coupon's beneficiaries would take a governance vote every round, and nothing casts it — so the coupons expire unclaimed.New behavior
One vote up front, then no human action per round. The consortium votes once to create an on-ledger delegation that carries the decparty's authority with the beneficiary split baked in. After that, any single member's node reassigns each round's coupons with a plain 1-of-n ledger command — only ever to that pre-voted split. Beneficiaries then self-mint. Any one live member keeps it going.
The change that enables it
CouponReassignmentDelegation— signatory = decparty, withassigners,splitanddsobaked in at creation. Itsnonconsuming Delegation_Assign(controller = any oneassigner) reassigns caller-supplied coupons to the contract'ssplit. The split is never a caller argument, and each coupon is fetched as splice's concreteRewardCouponV2and must carry the delegation'sdso.SetupCouponReassignmentDelegation/RevokeCouponReassignmentDelegation— the only step that needs a vote.reward_automationtask — each tick reads the active delegation and the decparty's unassigned coupons, takes every coupon more than a short margin from expiry, and drains the whole set in chunked transactions. A failed chunk ends the tick.Caveats
read_as), so governance membership alone does not make a node an assigner. See Deployment constraint below.providerIsObserver = falseare invisible to the automation and expire silently (#288).reward_max_creates = 100(per-transaction create budget) is an unmeasured guess; it is config-tunable, so the ceiling can be measured without a rebuild.Details
What changed
governance-rewards-automation-v1-rc20.1.0:CouponReassignmentDelegation(+Delegation_Assign/Delegation_Revoke), the two governance actions, and GWT (TestHarness) tests.ProposalType::{Setup,Revoke}CouponReassignmentDelegation+ validation + serializer; thereward_automationmodule (active_delegation,select_assignable,chunk_size,drain_assignable,run_reassign_once,submit_delegation_assignwithact_as = [assigner]/read_as = [decparty]), wired into the per-node loop.DECPM_REWARD_MAX_CREATES(100) andDECPM_REWARD_MIN_EXPIRY_MARGIN_SECS(120).POST /governance/proposenow returns 503 (was 400) when a required governance package id is unconfigured — the request is valid, the node isn't provisioned. It returns 409 for aSetupthat omitsprior_delegationwhile one is active, and 503 when it cannot read the ledger to tell (refusing beats admitting a second delegation on a transport blip).Three design decisions worth knowing
RewardCoupon_AssignBeneficiaries, and the per-beneficiary coupons inherit the originalexpiresAt— so assigning early neither consumes the coupon nor shortens its life, while holding it back only eats into the beneficiary's minting window. Selection readsexpiresAtalone.chunk / intervaland let a backlog grow. Chunks are sized in output creates (max_creates / beneficiary_count), because one assign createscoupons × beneficiariescontracts.ABORTED,NOT_FOUNDandFAILED_PRECONDITION. The skip lasts one tick; nothing is quarantined across ticks.Security
The split lives on-ledger and
Delegation_Assignreads it (newBeneficiaries = split), never the caller's; the choice iscontroller assignerplusassertMsg (assigner \elem` assigners).executeImpl` re-validates the split at execute time — including uniqueness, without which a duplicate beneficiary would make every later assign fail.Two ways the interface contract id could be abused, both found in review and fixed here:
RewardCoupon_AssignBeneficiariesiscontroller (view this).provider, so a template merely claimingprovider = decpartycould satisfy that controller with the authority this delegation lends, then run its own choice body with it. The choice now fetches the primary as the concreteRewardCouponV2, which fails for any other implementation.dsoisRewardCouponV2's only signatory, so anyone can mint a genuine coupon naming themselvesdsoand this decparty asprovider. As a batch's primary it made splice reject every real coupon alongside it — one contract could stall the engine. The delegation now carries the DSO fixed by the same vote, the choice rejects any other, and the automation filters such coupons out before they reach a batch.Where the L3 claim stops. Which beneficiaries a delegation can assign to is enforced by construction. Which delegation is authoritative is not, and cannot be — contract keys give no cross-participant uniqueness and
executeImplcannot query the ACS. That is the one place fairness rests on convention — a propose-time guard, plus the automation preferring the newest delegation — rather than on the ledger.Deployment constraint (worth putting in ops docs)
submit_delegation_assignusesact_as = [assigner]/read_as = [decparty], so an assigner's participant must also host the decparty. On devnetcbtc-networkis hosted oniBTC-validator-1/2andbitsafe-validator-1, which is why nodes 1/2/4 can assign — while node-3 holds a genuine governance member party (attestor-3) and cannot assign at all, becauseiBTC-validator-3does not hostcbtc-network. Conversely, an assigner does not need to be a governance member: the DAML validates only thatassignersis non-empty and unique.Verification
cargo fmt --checkclean;clippy --all-targets --all-features -D warnings= 0; all 10 CI checks green on2847986(themainmerge), including the localnet integration suite and DAML tests.mainwas merged rather than rebased — a rebase re-hit an already-resolved conflict inhandlers/governance.rsat commit 15 of 86, while the merge was conflict-free.[50, 50, 20]; a failed chunk submits once and stops; chunks committed before a failure are kept.test_foreign_dso_coupon_rejected,test_setup_duplicate_beneficiary_rejected(DAML — verified to fail without its assert),parse_unassigned_coupon_rejects_a_foreign_dso,creates_second_delegation_only_for_an_unnamed_replacement(Rust).Devnet exercise — ✅ PASSED (2026-08-03, on
2847986)DAR vetted on all three participants hosting
cbtc-network; one 2-of-2 vote created the delegation; a second vote withprior_delegationreplaced it, leaving exactly one active — the singleton swap works.All three assigners assigned independently — 1-of-n proven for the first time. From PQS
exercises(…:Delegation_Assign)grouped byargument->>'assigner':bitsafe-validator-1Split exact for each — e.g.
6677.0466857136 / 1669.2616714284and537.3471606423 / 134.3367901606— 80.0000 % / 20.0000 %, ratio 4.000. Before this run attestor-2 had 15 attempts and 0 successes, so the redundancy had never actually been exercised.Poll rate, not phase offset, decides who does the work. Coupon inflow is ~1 per 10 min, so the node that polls most often takes essentially all of them. Demonstrated three times: whichever node was given a 120s interval against the others' 600s took the next coupon within ~2 minutes. Staggering equal intervals is not enough. Relevant to the health signal in #278.
Abort-only validated by a real 3-day outage (devnet's post-LSU failure, 07-29 → 07-30). Five consecutive ticks each hit a different ledger failure, aborted on the first chunk with
assigned=0, and the engine recovered unattended with no stuck state:NOT_SEQUENCED_TIMEOUT→SEQUENCER_BACKPRESSURE→REQUEST_TIME_OUT→LOCAL_VERDICT_TIMEOUT(×2) → recovered,count=27. Cost: the 20 coupons it kept failing on aged past the 36h TTL. No localnet test can produce this class of failure.Tick interval
The interval does not bound throughput (a tick drains its set); it trades assignment latency against transaction count, and is not safety-critical. Devnet runs 6h (dlc-infra #149) — provisional, and marked so in the manifests. Mainnet is deliberately unset: mainnet has no
RewardCouponV2(app rewards there areAppRewardCouponwith the beneficiary set at mint), so there is no reassignment gap to close yet.Notes
governance-rewards-v1→governance-rewards-automation-v1-rc20.1.0 (whole package, Mode-B actions included).CouponReassignmentDelegationgained a requireddso : Party, then a requiredextraArgsonDelegation_Assign; Smart Contract Upgrade permits neither in place. Nothing was owed to the old lineage — it existed only on devnet, and mainnet has nogovernance-rewardscontracts at all. The old delegation stays active under the old package and is inert, since the new automation reads only the new package.-v1asserts the DAML model is frozen, and it moved twice during review. It becomes-v1in the last change before merge, once no further model change is expected.default_package_configpointsgovernance_rewardsat the new name, the whole package moved (Mode B included), anddars_dir()is the runtime data dir — so the DAR does not ship in the image. Vet the new DAR on every participant first, then roll the binary. Rolling first leaves each node resolving a package nobody has vetted, and it breaks Mode B on start (SetupMintingDelegation,AcceptExternalPartySetupmoved packages too, unchanged), not just reassignment.splice-amuletdata-dependency moves 0.1.17 → 0.1.19, needed to name the concreteRewardCouponV2.main).Design doc:
docs/coupon-reassignment/design.mdon this branch. Companion: #256 (Mode B).Closes #277 (batch cap bounded the wrong quantity; a failed batch never split).
Follow-ups: #271 (devnet target for the reward IT) · #272 (test-mode
/contracts/queryinterface-filter cleanup) · #278 (reward-automation health signal) · #285 (assigners are a snapshot vs live membership) · #286 (split invariants belong on the templateensure) · #287 (gating paths untested) · #288 (providerIsObserver = falsecoupons invisible) · #289 (test-mode look-alike delegation).