Skip to content

CIP-104 Mode A coupon-reassignment automation - #255

Open
gyorgybalazsi wants to merge 97 commits into
mainfrom
feat/governance/coupon-reassignment-automation
Open

CIP-104 Mode A coupon-reassignment automation#255
gyorgybalazsi wants to merge 97 commits into
mainfrom
feat/governance/coupon-reassignment-automation

Conversation

@gyorgybalazsi

@gyorgybalazsi gyorgybalazsi commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Old behavior
A decentralized party (decparty — e.g. cbtc-network) earns CIP-104 app rewards as RewardCouponV2 coupons, 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

  • DAML CouponReassignmentDelegation — signatory = decparty, with assigners, split and dso baked in at creation. Its nonconsuming Delegation_Assign (controller = any one assigner) reassigns caller-supplied coupons to the contract's split. The split is never a caller argument, and each coupon is fetched as splice's concrete RewardCouponV2 and must carry the delegation's dso.
  • Governance actions SetupCouponReassignmentDelegation / RevokeCouponReassignmentDelegation — the only step that needs a vote.
  • Per-node reward_automation task — 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.
  • Localnet integration test in CI — seeds 60 unassigned coupons and asserts the automation reassigns all of them to the voted split.

Caveats

  • "Reward realized end-to-end" also needs each beneficiary to self-mint before expiry — a precondition, out of scope.
  • Must not run alongside Mode B (feat(governance-rewards): AcceptExternalPartySetup action #256) on the same decparty; they compete for the same coupons.
  • An assigner's participant must also host the decparty (read_as), so governance membership alone does not make a node an assigner. See Deployment constraint below.
  • With assigners on equal tick intervals, one node does all the work — liveness is fine, load sharing is not. See Verification.
  • The TLS ledger path is unexercised at runtime (devnet runs with TLS off). Exercise it on a test network before mainnet.
  • The split must sum to exactly 1.0, compared as exact Decimal, so an even 3-way split is not expressible — balance the last entry by hand. And nothing is implicitly left to the decparty: to keep a remainder it must be listed as its own beneficiary. The proposer form states both before you type anything, and validates the split as scaled integers so the client agrees with the ledger exactly rather than within a float tolerance. The API validation error and design §8 carry them too, for anyone proposing over the API.
  • A second live delegation is a governance accident the ledger cannot prevent. The automation takes the newest by created-event offset, so assignment continues on the split the latest vote produced. What remains is that the superseded delegation stays exercisable by a direct ledger submit; the propose-time 409 guard is L2 and a direct submit bypasses it.
  • Coupons minted with providerIsObserver = false are 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.
  • A coupon that can never be exercised stalls the batch behind it. Accepted rather than worked around — skipping would lose the coupon while hiding the cause — so the failing coupon id is logged for alerting (#278).

Details

What changed

  • DAML package governance-rewards-automation-v1-rc2 0.1.0: CouponReassignmentDelegation (+ Delegation_Assign / Delegation_Revoke), the two governance actions, and GWT (TestHarness) tests.
  • Rust: ProposalType::{Setup,Revoke}CouponReassignmentDelegation + validation + serializer; the reward_automation module (active_delegation, select_assignable, chunk_size, drain_assignable, run_reassign_once, submit_delegation_assign with act_as = [assigner] / read_as = [decparty]), wired into the per-node loop.
  • Two operator knobs, both previously requiring a rebuild to change: DECPM_REWARD_MAX_CREATES (100) and DECPM_REWARD_MIN_EXPIRY_MARGIN_SECS (120).
  • API change: POST /governance/propose now 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 a Setup that omits prior_delegation while 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

  • No minimum-age gate on selection. Splice puts no maturity precondition on RewardCoupon_AssignBeneficiaries, and the per-beneficiary coupons inherit the original expiresAt — so assigning early neither consumes the coupon nor shortens its life, while holding it back only eats into the beneficiary's minting window. Selection reads expiresAt alone.
  • A tick drains its whole set, in successive chunked transactions. The chunk bounds one transaction, not a tick's work; applying it per tick would make throughput chunk / interval and let a backlog grow. Chunks are sized in output creates (max_creates / beneficiary_count), because one assign creates coupons × beneficiaries contracts.
  • A failed chunk is handled by cause. A stale view — another assigner committed first, or the coupon expired — 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 different: the drain bisects the chunk, finds the one coupon at fault, logs it at ERROR and pays the rest, so one un-exerciseable coupon can no longer stall assignment behind it. The two are told apart by the Canton error id, never the gRPC code — devnet contention arrives as ABORTED, NOT_FOUND and FAILED_PRECONDITION. The skip lasts one tick; nothing is quarantined across ticks.

Security
The split lives on-ledger and Delegation_Assign reads it (newBeneficiaries = split), never the caller's; the choice is controller assigner plus assertMsg (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_AssignBeneficiaries is controller (view this).provider, so a template merely claiming provider = decparty could 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 concrete RewardCouponV2, which fails for any other implementation.
  • dso is RewardCouponV2's only signatory, so anyone can mint a genuine coupon naming themselves dso and this decparty as provider. 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 executeImpl cannot 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_assign uses act_as = [assigner] / read_as = [decparty], so an assigner's participant must also host the decparty. On devnet cbtc-network is hosted on iBTC-validator-1/2 and bitsafe-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, because iBTC-validator-3 does not host cbtc-network. Conversely, an assigner does not need to be a governance member: the DAML validates only that assigners is non-empty and unique.

Verification

  • cargo fmt --check clean; clippy --all-targets --all-features -D warnings = 0; all 10 CI checks green on 2847986 (the main merge), including the localnet integration suite and DAML tests. main was merged rather than rebased — a rebase re-hit an already-resolved conflict in handlers/governance.rs at commit 15 of 86, while the merge was conflict-free.
  • Localnet e2e in CI: seeds 60 coupons at the real 36h TTL (deliberately more than one chunk of 50), creates the delegation with one vote, and asserts every seeded coupon archived and the 0.8/0.2 split by value across the set.
  • Unit coverage for the drain rules, which no e2e can reach: 120 coupons at chunk 50 submit [50, 50, 20]; a failed chunk submits once and stops; chunks committed before a failure are kept.
  • Security guards are pinned by tests, since neither devnet nor the IT reaches them: 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 with prior_delegation replaced 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 by argument->>'assigner':

    Assigner Node Exercises Coupons
    attestor-1 node-1 6 16
    attestor-2 node-2 9 9
    bitsafe-validator-1 node-4 3 3

    Split exact for each — e.g. 6677.0466857136 / 1669.2616714284 and 537.3471606423 / 134.336790160680.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_TIMEOUTSEQUENCER_BACKPRESSUREREQUEST_TIME_OUTLOCAL_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 are AppRewardCoupon with the beneficiary set at mint), so there is no reassignment gap to close yet.

Notes

  • The DAML package was renamed rather than version-bumped: governance-rewards-v1governance-rewards-automation-v1-rc2 0.1.0 (whole package, Mode-B actions included). CouponReassignmentDelegation gained a required dso : Party, then a required extraArgs on Delegation_Assign; Smart Contract Upgrade permits neither in place. Nothing was owed to the old lineage — it existed only on devnet, and mainnet has no governance-rewards contracts at all. The old delegation stays active under the old package and is inert, since the new automation reads only the new package.
  • The name stays a release candidate on purpose. -v1 asserts the DAML model is frozen, and it moved twice during review. It becomes -v1 in the last change before merge, once no further model change is expected.
  • Deploy order is vet-then-roll, and it is not optional. default_package_config points governance_rewards at the new name, the whole package moved (Mode B included), and dars_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, AcceptExternalPartySetup moved packages too, unchanged), not just reassignment.
  • Its vendored splice-amulet data-dependency moves 0.1.17 → 0.1.19, needed to name the concrete RewardCouponV2.
  • Stacked on the Mode-B one-shot collection path (feat(governance-rewards): AcceptExternalPartySetup action #256, merged to main).

Design doc: docs/coupon-reassignment/design.md on 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/query interface-filter cleanup) · #278 (reward-automation health signal) · #285 (assigners are a snapshot vs live membership) · #286 (split invariants belong on the template ensure) · #287 (gating paths untested) · #288 (providerIsObserver = false coupons invisible) · #289 (test-mode look-alike delegation).

Copilot AI 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.

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 AssignRewardBeneficiaries GovernableAction that exercises RewardCoupon_AssignBeneficiaries with 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.

Comment thread crates/decman/src/server/types.rs Outdated
Comment thread docs/coupon-reassignment/m1-m2-implementation-plan.md Outdated
Comment thread docs/coupon-reassignment/design.md Outdated
gyorgybalazsi added a commit that referenced this pull request Jul 20, 2026
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>
gyorgybalazsi added a commit that referenced this pull request Jul 20, 2026
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>
@gyorgybalazsi
gyorgybalazsi force-pushed the feat/governance/coupon-reassignment-automation branch from a163d29 to d0364fc Compare July 20, 2026 11:47
@gyorgybalazsi
gyorgybalazsi changed the base branch from main to feat/accept-external-party-setup July 20, 2026 11:48
Base automatically changed from feat/accept-external-party-setup to main July 20, 2026 12:45
gyorgybalazsi and others added 20 commits July 20, 2026 16:28
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>
Comment on lines +114 to +236
// ============================================================================
// 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)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +251 to +255
/// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

unnecessary commentary

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Trimmed in 1402db7.

Comment on lines +261 to +286
/// 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()
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should be in a utils file, what do you think. Along with all the generic record parsing stuff. Shared across the whole repo.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

what is the keyless-singleton invariant? :D

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +336 to +353
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"
))
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we could just grab the latest delegation contract, instead of this complicated logic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +400 to +405
let mut out = Vec::new();
for (cid, rec) in records {
if let Some(coupon) = parse_unassigned_coupon(&cid, &rec, decparty, dso)? {
out.push(coupon);
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

couldn't this be a records.map()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +424 to +430
// `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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the same unncessary comment again. I think it's obvious we check the dso on the contract!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair — deleted in 1402db7. The check reads for itself; the rationale lives in design §12 and in the DAML choice.

Comment on lines +689 to +694
/// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread crates/decman/src/server/types.rs Outdated
Comment on lines +649 to +652
/// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

same comment again, not needed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Trimmed in 1402db7.

Comment thread crates/decman/src/server/types.rs Outdated
Comment on lines +857 to +860
let zero = "0"
.parse::<DamlDecimal>()
.expect("'0' is a valid DamlDecimal");
let one: DamlDecimal = "1".parse().expect("'1' is a valid DamlDecimal");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this necessary? We usually don't let expects stay in production code. This seems a bit silly anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread crates/decman/src/cli.rs
Comment on lines +252 to +257
/// 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>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

yeah lets switch to v1 before we merge this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

but only after we have made and tested any changes to the DAML ofc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +67 to +89
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,
})
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

doesn't this logic exist in many places already?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 scolear left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Going to be very nice, but I did leave a lot of comments.

gyorgybalazsi and others added 3 commits August 7, 2026 10:58
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>
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 schronck left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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>
@gyorgybalazsi

Copy link
Copy Markdown
Contributor Author

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 it

Fixed as you described: active_created_records keeps created.template_id in the interface branch, and unassigned_coupons now admits only contracts created by Splice.Amulet:RewardCouponV2. The reader admits exactly what Delegation_Assign accepts, so the class is gone without touching the drain rule.

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 fetch that then rejects the batch. That note was wrong; the design doc §9/§11 now says what actually contains this.

additionalCoupons — guarded, and the test does not discriminate

Delegation_Assign now runs the fetch + dso assert over primaryCoupon :: additionalCoupons.

Worth reporting precisely, because it changes what the test is worth. I added test_foreign_dso_in_additional_rejected, then narrowed the choice back to the primary and rebuilt the dependency DAR to check the test discriminates. It still passed. splice does fetch the additional coupons with the primary's dso and rejects them itself, so today two layers enforce this and no test in that package can separate them.

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 drain

Rewritten in 9f9aa28, closing #277. scolear asked for the same thing. It classifies by Canton error id rather than gRPC code — devnet contention arrives as ABORTED, NOT_FOUND and FAILED_PRECONDITION, so a code-based rule would bisect a chunk another node had just assigned correctly — then bisects only a rejected command. The skip lasts one tick, so nothing is quarantined across ticks and a misclassification costs one tick.

Smaller

409 guard. Both halves fixed. propose_action matches the proposal shape first and only then reads the ACS, so every other propose stops paying a GetLedgerEnd + GetActiveContracts. The swallowed error is now a 503 with a log — refusing the proposal beats admitting a second delegation on a blip.

Your sub-point about active_delegation erroring "precisely when there is more than one delegation live" no longer applies: it now takes the newest instead of erroring (scolear's comment, done in 1402db7). So that specific hole closed on its own, but the swallow was still wrong and is gone.

Panics. The reachable one is fixed — the header parse returns an error instead of unwrap, with a comment saying why it matters in a detached task. The split_first().expect(...) I left: the drain builds its ranges non-empty by construction, including the bisect halves. General panic-resilience for the loop is a bigger change and belongs with #278, which is what would tell you the task died.

Package rename. Gyorgy's call was -rc2, not -v1. I have put your SCU argument to him directly — see the reply below, I think it deserves an answer rather than a merge-blocking disagreement.

Nits. provider and amount are gone from CouponInfo; the amount is still decoded and discarded, because a matching coupon that fails to decode should error rather than be assigned against silently. Two DARs this branch added are removed: governance-rewards-v1-0.1.2 and the now-superseded -rc1. Note -rc1 is what devnet currently runs, so it is recoverable from branch history until rc2 is deployed there. The IT dso gap is filed as #310 — it needs a dedicated localnet DSO party and a coupon from a different minter, which is more fixture plumbing than I wanted to add mid-review.

Verified locally: 335 Rust unit tests, clippy clean, 18 assign-test DAML scripts, 56 core-test scripts.

@gyorgybalazsi

gyorgybalazsi commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

On rc2 vs v1 — @schronck raises a cost I had not weighed

His 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 governance-rewards-v1. "Do it now, not after."

The counter-argument, and the reason -rc2 is what is on the branch: -v1 is a claim that the DAML model is frozen, and this round moved it twice (extraArgs, then the per-coupon dso guard). Taking -v1 today and then needing a third non-upgradeable change means renaming away from -v1, which is worse than another rc.

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 -v1 before it goes anywhere near mainnet, and the DAR needs a devnet round-trip either way.

Deploy ordering — @schronck is right that the PR body understates it

He notes default_package_config flips governance_rewards to the new name and the whole package moved, Mode B included, while dars_dir() is the runtime data dir so the DAR does not ship in the image. Correct, and the body only covers the stale delegation today.

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. SetupMintingDelegation and AcceptExternalPartySetup moved packages too, unchanged, so your one-shot collection path depends on this deploy order exactly as reassignment does.

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>
@gyorgybalazsi

Copy link
Copy Markdown
Contributor Author

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: setup_coupon_reassignment_delegation is automation-only and GovernanceSection.tsx rejects it by design, which is why the devnet vote went in over the API. So both facts now live where a proposer actually is:

  • validate_reward_beneficiaries says how to fix it, not just what is wrong — that the sum is exact Decimal so the last entry must be balanced by hand, and that a remainder for the decparty needs the decparty listed as its own beneficiary.
  • The OpenAPI schema doc on new_beneficiaries carries both, with the 0.3333333333 / 0.3333333333 / 0.3333333334 example.
  • Design §8 has them, and says plainly that there is no UI so the API error is the surface.

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.

providerIsObserver = false. #288, unchanged — nothing on the client can fix it, and it needs the same monitoring as the poison-coupon case.

Single-delegation rule. You marked it acceptable. One thing changed under it anyway: active_delegation now takes the newest instead of refusing, so a governance mistake no longer stops assignment. The residual — a superseded delegation stays exercisable by direct submit — is unchanged and still in §12.

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 #2789f9aa28, closing #277.

Also in this push: the two pub(crate) widenings from the deleted auto-confirm engine are private again (see the thread on governance.rs), and the PR body now states the vet-then-roll deploy order @schronck flagged, the rc2-not-v1 reasoning, and the 400→503 change.

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>
@gyorgybalazsi

Copy link
Copy Markdown
Contributor Author

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:

The split below is baked into the delegation. Changing it later needs another vote. Two rules the ledger enforces exactly, and which reject a vote at execute:

  • Percentages must sum to exactly 1.0, compared as exact Decimal. An even 3-way split is not expressible — use 0.3333333333 / 0.3333333333 / 0.3333333334.
  • Nothing is implicitly left to this party. To keep a remainder, add this party as its own beneficiary.

One detail worth flagging, because the obvious implementation is wrong. The existing validateBeneficiaryWeights sums with parseFloat and accepts Math.abs(sum - 1.0) < 1e-9. Three shares of 0.3333333333 sum to 0.9999999999, which is within that tolerance — so a float check passes a split the ledger rejects, which is precisely the failure you described. The new validator sums the decimal strings as scaled integers at Daml's 10-place scale and compares to exactly 1.0, so the client agrees with the ledger rather than approximately agreeing.

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: tsc -b clean, npm run build succeeds, no new eslint findings (the 21 in that file are pre-existing, at lines 307-1087 and 4316). The API-side error message and design §8 from 2d57b15 stay — they are the fallback for anyone still proposing over the API.

gyorgybalazsi and others added 2 commits August 7, 2026 16:48
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>
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.

reward automation: MAX_BATCH bounds the wrong quantity and a failed batch never splits

4 participants