Skip to content

CIP-104 Mode A coupon-reassignment automation#255

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

CIP-104 Mode A coupon-reassignment automation#255
gyorgybalazsi wants to merge 73 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 consortium's decentralized party (decparty — e.g. cbtc-network) earns CIP-104 app rewards as RewardCouponV2 coupons, but in that on-chain capacity it has no wallet: it can act only through slow m-of-n threshold governance. Nobody assigns each coupon's beneficiaries in time, so the coupons expire unclaimed every round. (Devnet: 118 coupons, ~43k CC, all beneficiary = null, 0 collected.)

New behavior
The consortium votes once to create an on-ledger delegation that carries the decparty's authority with the beneficiary split baked in. Thereafter any single member's node reassigns each round's unassigned coupons directly — a plain 1-of-n ledger command, no per-round vote — but only to that pre-voted split. Beneficiaries then self-mint. One vote up front, no human clicks per round, and any one live member keeps it going. The reassignment and the 0.8/0.2 split are now regression-tested on localnet every CI run (the test harness seeds its own coupons), not just verified once by hand on devnet.

The change that enables it

  • DAML CouponReassignmentDelegation (signatory = decparty, assigners = members, split baked in) with a nonconsuming Delegation_Assign choice — controller = any single assigner (1-of-n) — that reassigns caller-supplied coupons to the contract's split. The split is never a caller argument (the security boundary). Created / replaced / revoked via SetupCouponReassignmentDelegation / RevokeCouponReassignmentDelegation governance actions (the only step that takes a vote).
  • Per-node background automation (reward_automation): reads the active delegation + the decparty's unassigned coupons, batches by TTL-watermark with a minting margin, and exercises Delegation_Assign as a plain command on a timer tick.
  • Rust ProposalType plumbing for the setup/revoke actions.
  • A localnet integration test that seeds unassigned RewardCouponV2 coupons and asserts the automation reassigns them to the voted split — turning the previously devnet-only, manually-verified path into deterministic CI coverage.

Caveats

  • "Reward realized end-to-end" = this PR plus each beneficiary's own agent self-minting before expiry (a precondition, out of scope).
  • Must not run on a decparty that also runs the Mode-B one-shot collection (feat(governance-rewards): AcceptExternalPartySetup action #256) — they compete for the same coupons. (Mode-B was paused on cbtc-network for the devnet exercise.)
  • The localnet reward IT now runs in CI every run. The devnet target for that IT is a follow-up (#271) and stays gated behind DECPM_IT_REWARD; the driven non-assigner negative remains covered only by a DAML unit test.
  • Localnet test/prod query divergence: on localnet (--features test-mode) /contracts/query can't use interface filters, so the IT queries the concrete Splice.Amulet:RewardCouponV2 template rather than the RewardCoupon interface production uses. Known, tracked in #272 (likely a deletable stale workaround — the automation's own interface read does work in test-mode).

Details

What changed

  • DAML (governance-rewards → 0.1.3): CouponReassignmentDelegation (+ Delegation_Assign / Delegation_Revoke), SetupCouponReassignmentDelegation, RevokeCouponReassignmentDelegation; GWT (TestHarness) tests.
  • Rust: ProposalType::{Setup,Revoke}CouponReassignmentDelegation + validate + serializer; reward_automation module — active_delegation reader, select_batch, run_reassign_once executor (submit_delegation_assignDelegation_Assign exercise, act_as = [assigner] / read_as = [decparty]), wired into the per-node loop run_once_for_party.
  • Localnet reward IT (crates/decman/tests/): a seed_reward_coupons phase that creates unassigned RewardCouponV2 coupons via the JSON Ledger API as the localnet DSO stand-in; a ledger_api helper module (command builder + decoded ACS read tolerant of array/NDJSON); a poll-then-hard-fail precondition and a 0.8/0.2 split-by-value assertion in coupon_reassignment; the governance-rewards DAR added to the localnet DAR distribution; wired to run every CI run (localnet unconditional, devnet opt-in). Also made the shared propose_confirm_execute helper target its own proposal (by label/cid) instead of assuming it's the only pending action — a prior phase (notification_feed) leaves an unrelated proposal pending.

Security
The split lives on-ledger in the delegation and Delegation_Assign reads it (newBeneficiaries = split) — never from the caller; the choice is controller assigner + assert (assigner \elem` assigners)(a genuine 1-of-n gate, not escalatable).SetupCouponReassignmentDelegation.executeImpl` re-validates the split at execute time, so a direct ledger submit can't bake in a bad split. Fairness is enforced in L3 DAML by construction, not by trusting any node.

Verification

  • cargo fmt --check clean; cargo clippy --all-targets --all-features -- -D warnings = 0; cargo test -p decman — 287 lib + 38 integration green.
  • dpm build --all green; DAML test packages green.
  • The Delegation_Assign template_id module path (the core runtime action) is locked by a dedicated unit test.
  • Localnet e2e in CI (run 30107501088, all jobs green): the full governance_workflows suite passes including the reward phase — it seeds an unassigned coupon, creates the delegation with one vote, the per-node automation reassigns it (Delegation_Assign, coupon archived), and the test asserts the 0.8/0.2 split by value (beneficiary 80.0 vs operator 20.0, 4:1). test result: ok. 1 passed.
  • Devnet e2e: DECPM_IT_REWARD-gated; devnet target wiring is a follow-up (IT: wire TestTarget::Devnet for CIP-104 coupon-reassignment (Option B) #271).

Devnet exercise (pre-merge) — ✅ PASSED

  • Deployed image f6a716f to dec-party-manager-1/2/4; governance-rewards-v1 DAR uploaded + vetted; Mode-B paused on cbtc-network.

  • One governance vote (2-of-2) created the on-ledger CouponReassignmentDelegation; the per-node loop then reassigned each ripe coupon automatically (attestor-2), no errors, no per-round vote.

  • Split verified on-ledger by value (PQS archives()), across 91 coupon pairs:

    Beneficiary Coupons Total amount Share
    cbtc-beneficiary 91 27,689.39 79.9998 %
    operator (auth0_007c65…) 91 6,922.35 20.0002 %

    Ratio 4.000 : 1 — exactly the voted 0.8 / 0.2.

  • Batching confirmed: batch size scales with the loop tick interval (batch ≈ inflow × interval). At the exercise's 60s tick each coupon was assigned individually (count=1); raising the interval to 30 min produced count=3 batches, as predicted.

Tick-interval tuning

  • Devnet set to 6h (DECPM_REWARD_AUTOMATION_INTERVAL_SECS=21600, dlc-infra bug(parties): list_my_owner_keys times out on devnet — RequestOwnerKeys Noise round trip never returns peer's keys #149): devnet inflow ~6 coupons/hr → ~36/batch (under MAX_BATCH=50), ~4 assigns/day, ~22h slack to the 34h assign deadline. The binding constraint is MAX_BATCH=50, not the 36h TTL.
  • Mainnet interval is deliberately NOT set — insufficient info. Mainnet has no RewardCouponV2: app rewards are the older AppRewardCoupon with the beneficiary already set at mint (no reassignment gap), so the target inflow isn't measurable there yet. Volume proxy: mainnet app-reward flow is ~14× devnet (~84/hr). If mainnet later adopts the RewardCouponV2 deferred-assignment model, MAX_BATCH=50 caps the interval at ~36 min (use ~20 min for headroom) and/or MAX_BATCH must be raised — the devnet 6h would be catastrophic there (~500/tick vs a 50 cap). Production interval + MAX_BATCH are deferred until the mainnet coupon model is confirmed.

Notes


Design doc lives in docs/coupon-reassignment/design.md on this branch. Companion: #256 (Mode B).
Follow-ups: #271 (devnet target for the reward IT) · #272 (test-mode /contracts/query interface-filter cleanup).

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>
gyorgybalazsi and others added 11 commits July 24, 2026 14:25
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ation interval

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…imeout

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Localnet builds DecMan with --features test-mode, where /contracts/query
falls back to a WildcardFilter and matches by concrete template name, so an
interface query (RewardCoupon) never matches and returns empty. Query the
concrete Splice.Amulet:RewardCouponV2 template on localnet; devnet keeps the
interface query. Also drop the stale 'devnet-only' wording (the phase now runs
on localnet every CI run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ole pending

The helper assumed the cycle's proposal was the only pending domain action
(len()==1 / is_empty()). A prior phase (notification_feed) can leave an
unrelated proposal pending, which broke the first propose->confirm->execute run
after it (the reward phase). Match this cycle's proposal by action label / cid
instead. Behavior-preserving for clean-slate phases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

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

Copilot reviewed 33 out of 37 changed files in this pull request and generated 1 comment.

Comment thread crates/decman/src/server/handlers/governance.rs Outdated
gyorgybalazsi and others added 7 commits July 24, 2026 21:20
Extracting submit_proposal out of the propose_action handler collapsed
every failure into HTTP 500. Restore the pre-extraction semantics via a
SubmitProposalError that carries the intended status: 400 for bad input
(invalid admin party, no fundable holdings, bad create args, package not
configured), 502 for upstream registry-context fetch failures, 500 for
true internal errors (ledger connect/submit, missing contract id).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Splice.Api.Token.MetadataV1 exports no ready-made emptyExtraArgs; the
implementation builds ExtraArgs locally from emptyChoiceContext +
emptyMetadata. Fix the design/plan docs that claimed it was exported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…guard

The pre-extraction handler mapped "package not configured" to 400, but
the request is valid — the node just isn't provisioned to serve it — so
correct it to 503. Also document 502/503 in the propose_action OpenAPI
responses and add two network-free regression tests that pin distinct
submit_proposal failure classes (503 unconfigured, 400 bad input) to
distinct statuses, guarding against another collapse-to-500 refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
validate_reward_beneficiaries now rejects a split that repeats a
beneficiary, matching the on-ledger RewardCoupon_AssignBeneficiaries
`require "Beneficaries are unique"`. Without this a duplicated split
passes the governance vote and then fails every Delegation_Assign,
leaving a permanently unusable delegation. Add edge-case coverage for
the per-percentage bound, the 20/21 count boundary, and the duplicate
case (from the independent PR review, finding #1/#5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
select_batch caps a tick at MAX_BATCH and defers the rest; a sustained
backlog can let coupons expire unassigned. Extract the ripeness predicate
(is_ripe) and log a warning from run_reassign_once when the ripe set
exceeds MAX_BATCH, so silent truncation is observable (PR review #2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The setup/revoke shape tests only checked field labels. Descend into
values so a make_party<->make_contract_id swap, a renamed nested
`percentage`, or a wrong Optional shape fails CI instead of at ledger
submission. Adds an as_list test helper (PR review #4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only the empty-split reject was tested. Add a reusable negative driver
and cases for percentages not summing to 1.0, a percentage outside
(0,1], and more than 20 beneficiaries (PR review #5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gyorgybalazsi and others added 3 commits July 24, 2026 22:37
…lter

A configured reward_automation_interval_secs of 0 reaches
tokio::time::interval, which panics and silently kills the background
loop task; clamp 0 to 1s with a warning. Also extract the unassigned-
coupon fail-safe filter (provider == decparty, beneficiary is None) into
a pure parse_unassigned_coupon and unit-test it, so the security-relevant
skip logic is covered without the localnet IT (PR review low findings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RevokeCouponReassignmentDelegation::validate() accepted an empty
`delegation` cid, deferring the failure to ledger submission; reject it
at the boundary, consistent with AcceptExternalPartySetup (PR review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The README covered only the minting-delegation actions. Add the three
coupon-reassignment templates, the 1-of-n trust model and split security
boundary, propose/revoke examples, and the Mode-A/Mode-B exclusivity
caveat (PR review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cargo fmt --check flagged hand-written formatting in the review-fix edits
(governance.rs, action_serializer.rs, types.rs). No semantic change.

Co-Authored-By: Claude Opus 4.8 (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.

2 participants