diff --git a/crates/decman/frontend/src/components/GovernanceSection.tsx b/crates/decman/frontend/src/components/GovernanceSection.tsx index 59bdaefa..ccd0039e 100644 --- a/crates/decman/frontend/src/components/GovernanceSection.tsx +++ b/crates/decman/frontend/src/components/GovernanceSection.tsx @@ -1637,6 +1637,14 @@ export const GovernanceSection = ({ throw new Error( "Paid credential proposal forms are not implemented yet — use the Free direction or call the API directly.", ); + default: + // Automation-only proposal types (e.g. setup_coupon_reassignment_delegation, + // revoke_coupon_reassignment_delegation) have no UI form by design and are + // never offered in the menu; guard the exhaustiveness so `proposal` is + // always assigned. + throw new Error( + `Proposal type "${proposalType}" is automation-only and not available in the UI.`, + ); } const request: ProposeActionRequest = { diff --git a/crates/decman/src/cli.rs b/crates/decman/src/cli.rs index 30374f9f..f007c479 100644 --- a/crates/decman/src/cli.rs +++ b/crates/decman/src/cli.rs @@ -237,5 +237,22 @@ pub enum Commands { /// Backoff between attempts of the bounded peer-Noise retry wrapper, in milliseconds #[arg(long, env = "DECPM_NOISE_RETRY_BACKOFF_MS")] noise_retry_backoff_ms: Option, + + /// CIP-104 Mode A reward-automation loop tick interval, in seconds. + /// Enablement is on-ledger (presence of a CouponReassignmentDelegation); + /// this only controls cadence. Defaults to 300. + #[arg(long, env = "DECPM_REWARD_AUTOMATION_INTERVAL_SECS")] + reward_automation_interval_secs: Option, + /// 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, + /// Seconds a coupon must have left before expiry to be assigned. Guards + /// against a coupon vanishing mid-submission, not a minting reserve for + /// the beneficiary. Defaults to 120. + #[arg(long, env = "DECPM_REWARD_MIN_EXPIRY_MARGIN_SECS")] + reward_min_expiry_margin_secs: Option, }, } diff --git a/crates/decman/src/config.rs b/crates/decman/src/config.rs index 7b3fbce9..e3a406a3 100644 --- a/crates/decman/src/config.rs +++ b/crates/decman/src/config.rs @@ -250,6 +250,25 @@ pub struct NodeConfig { pub canton: CantonConfig, pub timeouts: Timeouts, pub noise_retry: NoiseRetryConfig, + /// Tick interval (seconds) for the CIP-104 Mode A reward-assignment + /// automation loop. Enablement is on-ledger (presence of a + /// `CouponReassignmentDelegation`), so this only controls cadence. Default 300s. + pub reward_automation_interval_secs: u64, + /// Output contracts one `Delegation_Assign` may create, which bounds the + /// coupons per transaction (`/ beneficiary_count`). The ledger's real + /// ceiling for this transaction shape is unmeasured — configurable so it can + /// be raised stepwise against a live ledger without a rebuild. Set too high, + /// the ledger rejects each chunk and the tick ends having assigned nothing, + /// so lower it if assigns start failing. Default 100. + pub reward_max_creates: usize, + /// How much time (seconds) a coupon must have left before expiry to be + /// assigned. This guards against a coupon vanishing between the ACS read + /// and the commit, which would fail its whole chunk — it is NOT a reserve + /// of minting time for the beneficiary. Withholding a coupon guarantees it + /// is never minted, whereas assigning it late still lets the beneficiary + /// try, so this should be a small submission-latency allowance rather than + /// a generous window. Default 120s. + pub reward_min_expiry_margin_secs: u64, /// Top-level Keycloak config for frontend website gating pub keycloak: Option, /// Top-level Auth0 config for frontend website gating (mutually exclusive @@ -275,6 +294,9 @@ impl Default for NodeConfig { canton: CantonConfig::default(), timeouts: Timeouts::default(), noise_retry: NoiseRetryConfig::default(), + reward_automation_interval_secs: 300, + reward_max_creates: 100, + reward_min_expiry_margin_secs: 120, keycloak: None, auth0: None, insecure: false, diff --git a/crates/decman/src/main.rs b/crates/decman/src/main.rs index cfcd6318..2751cbce 100644 --- a/crates/decman/src/main.rs +++ b/crates/decman/src/main.rs @@ -120,6 +120,9 @@ async fn main() -> Result { noise_retry_timeout_sec, noise_retry_max_attempts, noise_retry_backoff_ms, + reward_automation_interval_secs, + reward_max_creates, + reward_min_expiry_margin_secs, db_encryption_key, insecure, canton_hmac_secret, @@ -240,6 +243,15 @@ async fn main() -> Result { if let Some(v) = noise_retry_backoff_ms { config.noise_retry.backoff_ms = *v; } + if let Some(v) = reward_automation_interval_secs { + config.reward_automation_interval_secs = *v; + } + if let Some(v) = reward_max_creates { + config.reward_max_creates = *v; + } + if let Some(v) = reward_min_expiry_margin_secs { + config.reward_min_expiry_margin_secs = *v; + } config.insecure = *insecure; // Only ingest the unsafe HMAC settings when insecure mode is on; diff --git a/crates/decman/src/server/action_serializer.rs b/crates/decman/src/server/action_serializer.rs index c74e4507..edddf14f 100644 --- a/crates/decman/src/server/action_serializer.rs +++ b/crates/decman/src/server/action_serializer.rs @@ -16,14 +16,14 @@ use crate::{canton_id::CantonId, error::Result}; use super::types::{ ActionType, AppRewardBeneficiary, BillingParams, Claim, FarConfig, InstrumentAllowance, - InstrumentId, InstrumentIdentifier, ProposalType, VaultLimits, + InstrumentId, InstrumentIdentifier, ProposalType, RewardBeneficiary, VaultLimits, }; // ============================================================================ // Helper Functions // ============================================================================ -fn make_party(p: impl std::fmt::Display) -> Value { +pub(crate) fn make_party(p: impl std::fmt::Display) -> Value { Value { sum: Some(value::Sum::Party(p.to_string())), } @@ -53,13 +53,13 @@ fn make_bool(b: bool) -> Value { } } -fn make_contract_id(c: &str) -> Value { +pub(crate) fn make_contract_id(c: &str) -> Value { Value { sum: Some(value::Sum::ContractId(c.to_string())), } } -fn field(label: &str, value: Value) -> RecordField { +pub(crate) fn field(label: &str, value: Value) -> RecordField { RecordField { label: label.to_string(), value: Some(value), @@ -85,7 +85,7 @@ fn make_variant(constructor: &str, value: Value) -> Value { } } -fn make_list(values: Vec) -> Value { +pub(crate) fn make_list(values: Vec) -> Value { Value { sum: Some(value::Sum::List(List { elements: values })), } @@ -249,6 +249,14 @@ fn make_optional_numeric(opt: &Option) -> Value { } } +fn make_optional_contract_id(opt: &Option) -> Value { + Value { + sum: Some(value::Sum::Optional(Box::new(Optional { + value: opt.as_ref().map(|c| Box::new(make_contract_id(c))), + }))), + } +} + fn make_optional_bool(opt: &Option) -> Value { Value { sum: Some(value::Sum::Optional(Box::new(Optional { @@ -328,6 +336,13 @@ fn serialize_app_reward_beneficiary(b: &AppRewardBeneficiary) -> Value { ]) } +fn serialize_reward_beneficiary(b: &RewardBeneficiary) -> Value { + make_record(vec![ + field("beneficiary", make_party(&b.beneficiary)), + field("percentage", make_numeric(&b.percentage.to_string())), + ]) +} + fn serialize_instrument_identifier(i: &InstrumentIdentifier) -> Value { make_record(vec![ field("source", make_party(&i.source)), @@ -1184,6 +1199,54 @@ pub fn build_proposal_create_args( ], }, ), + ProposalType::SetupCouponReassignmentDelegation { + dso, + assigners, + new_beneficiaries, + prior_delegation, + } => ( + ProposalPackage::GovernanceRewards, + "Governance.Rewards.SetupCouponReassignmentDelegation", + "SetupCouponReassignmentDelegation", + Record { + record_id: None, + fields: vec![ + field("governanceParty", make_party(governance_party)), + field("proposer", make_party(proposer)), + field( + "priorDelegation", + make_optional_contract_id(prior_delegation), + ), + field("dso", make_party(dso)), + field( + "assigners", + make_list(assigners.iter().map(make_party).collect()), + ), + field( + "beneficiaries", + make_list( + new_beneficiaries + .iter() + .map(serialize_reward_beneficiary) + .collect(), + ), + ), + ], + }, + ), + ProposalType::RevokeCouponReassignmentDelegation { delegation } => ( + ProposalPackage::GovernanceRewards, + "Governance.Rewards.RevokeCouponReassignmentDelegation", + "RevokeCouponReassignmentDelegation", + Record { + record_id: None, + fields: vec![ + field("governanceParty", make_party(governance_party)), + field("proposer", make_party(proposer)), + field("delegation", make_contract_id(delegation)), + ], + }, + ), ProposalType::SetEnableResultContracts { registrar_service_cid, enable_result_contracts, @@ -2075,6 +2138,11 @@ mod tests { CantonId::new("p".to_string(), Namespace::new([0u8; NAMESPACE_LENGTH])) } + /// Parse a decimal literal in test fixtures, panicking on invalid input. + fn dec(s: &str) -> DamlDecimal { + DamlDecimal::parse(s).expect("valid decimal literal") + } + /// Unwrap a `Variant` value into `(constructor, inner)`. fn as_variant(value: &Value) -> (&str, &Value) { match &value.sum { @@ -2251,6 +2319,15 @@ mod tests { } } + /// Unwrap a `value::Sum::List` reference (for descending into a list `Value` + /// returned by `field_value`). + fn as_list(value: &Value) -> &[Value] { + match &value.sum { + Some(value::Sum::List(l)) => &l.elements, + other => panic!("expected List, got {other:?}"), + } + } + #[test] fn build_proposal_transfer_shape_and_nested_records() -> Result { let proposal = ProposalType::Transfer { @@ -2466,6 +2543,115 @@ mod tests { Ok(()) } + #[test] + fn build_proposal_setup_delegation_shape() -> Result { + let proposal = ProposalType::SetupCouponReassignmentDelegation { + dso: party_id(), + assigners: vec![party_id(), party_id()], + new_beneficiaries: vec![ + RewardBeneficiary { + beneficiary: party_id(), + percentage: dec("0.8"), + }, + RewardBeneficiary { + beneficiary: party_id(), + percentage: dec("0.2"), + }, + ], + prior_delegation: Some("00old".to_string()), + }; + let (package, module, entity, record) = + build_proposal_create_args("gov", "proposer", &proposal, None, None)?; + assert_eq!(package, ProposalPackage::GovernanceRewards); + assert_eq!( + module, + "Governance.Rewards.SetupCouponReassignmentDelegation" + ); + assert_eq!(entity, "SetupCouponReassignmentDelegation"); + assert_eq!( + owned_labels(&record), + [ + "governanceParty", + "proposer", + "priorDelegation", + "dso", + "assigners", + "beneficiaries" + ] + ); + + // priorDelegation: Some -> Optional(Some(ContractId "00old")). + match &field_value(&record, "priorDelegation").sum { + Some(value::Sum::Optional(opt)) => { + let inner = opt.value.as_ref().expect("priorDelegation should be Some"); + assert!( + matches!(&inner.sum, Some(value::Sum::ContractId(c)) if c == "00old"), + "priorDelegation inner must be ContractId(\"00old\"), got {:?}", + inner.sum + ); + } + other => panic!("priorDelegation must be Optional, got {other:?}"), + } + + // assigners: a list of two Party values. + let assigners = as_list(field_value(&record, "assigners")); + assert_eq!(assigners.len(), 2); + assert!( + assigners + .iter() + .all(|v| matches!(v.sum, Some(value::Sum::Party(_)))), + "assigners elements must be Party" + ); + + // beneficiaries: a list of {beneficiary: Party, percentage: Numeric}, + // carrying the 0.8 / 0.2 split in order. A regression swapping + // make_party <-> make_contract_id, or renaming `percentage`, fails here. + let benes = as_list(field_value(&record, "beneficiaries")); + assert_eq!(benes.len(), 2); + for (bene, expected_pct) in benes.iter().zip(["0.8", "0.2"]) { + let rec = as_record(bene); + assert_eq!(owned_labels(rec), ["beneficiary", "percentage"]); + assert!( + matches!( + field_value(rec, "beneficiary").sum, + Some(value::Sum::Party(_)) + ), + "beneficiary must be a Party" + ); + match &field_value(rec, "percentage").sum { + Some(value::Sum::Numeric(n)) => assert_eq!(n, expected_pct), + other => panic!("percentage must be Numeric, got {other:?}"), + } + } + Ok(()) + } + + #[test] + fn build_proposal_revoke_delegation_shape() -> Result { + let proposal = ProposalType::RevokeCouponReassignmentDelegation { + delegation: "00abc".to_string(), + }; + let (package, module, entity, record) = + build_proposal_create_args("gov", "proposer", &proposal, None, None)?; + assert_eq!(package, ProposalPackage::GovernanceRewards); + assert_eq!( + module, + "Governance.Rewards.RevokeCouponReassignmentDelegation" + ); + assert_eq!(entity, "RevokeCouponReassignmentDelegation"); + assert_eq!( + owned_labels(&record), + ["governanceParty", "proposer", "delegation"] + ); + // delegation: a ContractId carrying the passed cid. + assert!( + matches!(&field_value(&record, "delegation").sum, Some(value::Sum::ContractId(c)) if c == "00abc"), + "delegation must be ContractId(\"00abc\"), got {:?}", + field_value(&record, "delegation").sum + ); + Ok(()) + } + #[test] fn build_proposal_accept_transfer_shape_and_context_branches() -> Result { let proposal = ProposalType::AcceptTransfer { diff --git a/crates/decman/src/server/handlers/governance.rs b/crates/decman/src/server/handlers/governance.rs index 580eac36..984004f1 100644 --- a/crates/decman/src/server/handlers/governance.rs +++ b/crates/decman/src/server/handlers/governance.rs @@ -1040,50 +1040,82 @@ pub async fn get_governance_chain_audit( // Action Endpoints // ============================================================================ -/// Propose a domain governance action (creates a GovernableAction proposal contract) -#[utoipa::path( - tag = "Governance", - request_body = ProposeActionRequest, - responses( - (status = 200, description = "Proposal created", body = MessageResponse), - (status = 400, description = "Bad request", body = ErrorResponse), - (status = 401, description = "Unauthorized", body = ErrorResponse), - (status = 403, description = "Forbidden: admin role required", body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse) - ) -)] -#[post("/governance/propose")] -pub async fn propose_action( - http_req: HttpRequest, - data: web::Data, - body: web::Json, -) -> impl Responder { - if let Err(resp) = require_admin(&http_req, data.admin_role.as_deref()) { - return resp; +/// Error from [`submit_proposal`], pairing a message with the HTTP status the +/// handler should surface: bad input is 400, an upstream-registry fetch failure +/// is 502, an unprovisioned governance package is 503, and only a true internal +/// fault is 500. +#[derive(Debug)] +pub(crate) struct SubmitProposalError { + status: actix_web::http::StatusCode, + message: String, +} + +impl SubmitProposalError { + fn bad_request(message: impl Into) -> Self { + Self { + status: actix_web::http::StatusCode::BAD_REQUEST, + message: message.into(), + } } - if let Err(msg) = body.proposal.validate() { - return HttpResponse::BadRequest().json(ErrorResponse { - error: msg.to_string(), - }); + + fn bad_gateway(message: impl Into) -> Self { + Self { + status: actix_web::http::StatusCode::BAD_GATEWAY, + message: message.into(), + } } - let party_id = &body.party_id; - let (token, member_party_id) = match get_party_credentials(&data, party_id).await { - Some(creds) => creds, - None => { - return HttpResponse::Unauthorized().json(ErrorResponse { - error: "No credentials configured for party".to_string(), - }); + + /// The node is missing configuration required to serve this proposal (e.g. a + /// governance package id). The request is valid; the server isn't ready. + fn service_unavailable(message: impl Into) -> Self { + Self { + status: actix_web::http::StatusCode::SERVICE_UNAVAILABLE, + message: message.into(), } - }; + } - let audit_pool = data.db.clone(); - let audit_summary = crate::server::audit::proposal_summary(&body.proposal); - let audit_details = serde_json::to_string(&*body).unwrap_or_default(); - let audit_party_id = party_id.clone(); - let audit_member = member_party_id.clone(); + fn internal(message: impl Into) -> Self { + Self { + status: actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + message: message.into(), + } + } - let packages = packages(); + /// The HTTP status the handler should return for this failure. + pub(crate) fn status(&self) -> actix_web::http::StatusCode { + self.status + } +} +impl std::fmt::Display for SubmitProposalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for SubmitProposalError {} + +/// Build and submit a `GovernableAction` proposal-create command, returning the +/// created proposal contract id. +/// +/// Resolves any registry-backed transfer choice-context, builds the create +/// arguments, resolves the target package id, and submits over a fresh +/// `CommandServiceClient`. Callable off the HTTP path: failures surface as +/// [`SubmitProposalError`], which carries both a message and the status an HTTP +/// caller should return, so a non-HTTP caller can log the message and ignore +/// the status. +/// +/// `_rules_contract_id` is part of the propose API surface (used by the caller's +/// subsequent confirm step) but is not needed to create the proposal. +pub(crate) async fn submit_proposal( + config: &NodeConfig, + party_id: &CantonId, + _rules_contract_id: &str, + proposal: &ProposalType, + token: &str, + member_party_id: &CantonId, + packages: &PackageConfig, +) -> Result { // Resolve registry-backed context for token-standard transfer flows: // * `AcceptTransfer`: fetch the `transfer-rule` choice context the // `TransferInstruction_Accept` choice reads at execute time. Without it @@ -1104,7 +1136,7 @@ pub async fn propose_action( // expire and release its escrow instead of locking funds forever. The // window defaults to 24h but the caller may override it per-transfer. let now_micros = chrono::Utc::now().timestamp_micros(); - let transfer_validity = match &body.proposal { + let transfer_validity = match proposal { ProposalType::Transfer { validity_window_hours: Some(hours), .. @@ -1115,14 +1147,14 @@ pub async fn propose_action( _ => action_serializer::TransferValidity::from_now(now_micros), }; - let mut resolved_proposal = body.proposal.clone(); + let mut resolved_proposal = proposal.clone(); let transfer_choice_context = match &mut resolved_proposal { ProposalType::AcceptTransfer { transfer_instruction_cid, } => match fetch_accept_transfer_context( - &data.config, - Some(token.clone()), - data.config.canton.network, + config, + Some(token.to_string()), + config.canton.network, party_id, transfer_instruction_cid, ) @@ -1133,9 +1165,9 @@ pub async fn propose_action( tracing::warn!( "Failed to fetch AcceptTransfer choice context from registry: {e:#}" ); - return HttpResponse::BadGateway().json(ErrorResponse { - error: format!("Failed to fetch transfer choice context: {e}"), - }); + return Err(SubmitProposalError::bad_gateway(format!( + "Failed to fetch transfer choice context: {e}" + ))); } }, ProposalType::Transfer { @@ -1154,9 +1186,9 @@ pub async fn propose_action( let admin: CantonId = match instrument_id.admin.parse() { Ok(p) => p, Err(e) => { - return HttpResponse::BadRequest().json(ErrorResponse { - error: format!("Invalid instrument admin party id: {e}"), - }); + return Err(SubmitProposalError::bad_request(format!( + "Invalid instrument admin party id: {e}" + ))); } }; // The token-standard transfer factory rejects an empty @@ -1166,33 +1198,31 @@ pub async fn propose_action( // consume what it needs (returning change). if input_holding_cids.is_empty() { match select_input_holdings( - &data.config, + config, party_id, - Some(token.clone()), + Some(token.to_string()), &admin, &instrument_id.id, ) .await { Ok(cids) if cids.is_empty() => { - return HttpResponse::BadRequest().json(ErrorResponse { - error: format!( - "No holdings of instrument {} owned by {} to fund the transfer", - instrument_id.id, party_id - ), - }); + return Err(SubmitProposalError::bad_request(format!( + "No holdings of instrument {} owned by {} to fund the transfer", + instrument_id.id, party_id + ))); } Ok(cids) => *input_holding_cids = cids, Err(e) => { tracing::warn!("Failed to select input holdings for transfer: {e:#}"); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to select input holdings: {e}"), - }); + return Err(SubmitProposalError::internal(format!( + "Failed to select input holdings: {e}" + ))); } } } match fetch_factory_for_propose( - data.config.canton.network, + config.canton.network, ProposeTransferArgs { sender: party_id, receiver, @@ -1220,9 +1250,9 @@ pub async fn propose_action( } Err(e) => { tracing::warn!("Failed to fetch Transfer choice context from registry: {e:#}"); - return HttpResponse::BadGateway().json(ErrorResponse { - error: format!("Failed to fetch transfer factory: {e}"), - }); + return Err(SubmitProposalError::bad_gateway(format!( + "Failed to fetch transfer factory: {e}" + ))); } } } @@ -1230,72 +1260,56 @@ pub async fn propose_action( }; let (package_source, module_name, entity_name, create_args) = - match action_serializer::build_proposal_create_args( + action_serializer::build_proposal_create_args( &party_id.to_string(), &member_party_id.to_string(), &resolved_proposal, transfer_choice_context.as_ref().map(|r| &r.context), Some(transfer_validity), - ) { - Ok(args) => args, - Err(e) => { - return HttpResponse::BadRequest().json(ErrorResponse { - error: format!("Failed to build proposal create arguments: {e}"), - }); - } - }; + ) + .map_err(|e| { + SubmitProposalError::bad_request(format!( + "Failed to build proposal create arguments: {e}" + )) + })?; let package_id = match package_source { action_serializer::ProposalPackage::GovernanceCore => { - match packages.governance_core.as_deref() { - Some(pkg) => pkg, - None => { - return HttpResponse::BadRequest().json(ErrorResponse { - error: "governance_core package not configured".to_string(), - }); - } - } + packages.governance_core.as_deref().ok_or_else(|| { + SubmitProposalError::service_unavailable("governance_core package not configured") + })? } action_serializer::ProposalPackage::GovernanceRewards => { - match packages.governance_rewards.as_deref() { - Some(pkg) => pkg, - None => { - return HttpResponse::BadRequest().json(ErrorResponse { - error: "governance_rewards package not configured".to_string(), - }); - } - } - } - action_serializer::ProposalPackage::GovernanceTokenCustody => { - match packages.governance_token_custody.as_deref() { - Some(pkg) => pkg, - None => { - return HttpResponse::BadRequest().json(ErrorResponse { - error: "governance_token_custody package not configured".to_string(), - }); - } - } - } - action_serializer::ProposalPackage::GovernanceUtilityCredential => { - match packages.governance_utility_credential.as_deref() { - Some(pkg) => pkg, - None => { - return HttpResponse::BadRequest().json(ErrorResponse { - error: "governance_utility_credential package not configured".to_string(), - }); - } - } - } - action_serializer::ProposalPackage::GovernanceUtilityOnboarding => { - match packages.governance_utility_onboarding.as_deref() { - Some(pkg) => pkg, - None => { - return HttpResponse::BadRequest().json(ErrorResponse { - error: "governance_utility_onboarding package not configured".to_string(), - }); - } - } - } + packages.governance_rewards.as_deref().ok_or_else(|| { + SubmitProposalError::service_unavailable( + "governance_rewards package not configured", + ) + })? + } + action_serializer::ProposalPackage::GovernanceTokenCustody => packages + .governance_token_custody + .as_deref() + .ok_or_else(|| { + SubmitProposalError::service_unavailable( + "governance_token_custody package not configured", + ) + })?, + action_serializer::ProposalPackage::GovernanceUtilityCredential => packages + .governance_utility_credential + .as_deref() + .ok_or_else(|| { + SubmitProposalError::service_unavailable( + "governance_utility_credential package not configured", + ) + })?, + action_serializer::ProposalPackage::GovernanceUtilityOnboarding => packages + .governance_utility_onboarding + .as_deref() + .ok_or_else(|| { + SubmitProposalError::service_unavailable( + "governance_utility_onboarding package not configured", + ) + })?, }; let template_id = Identifier { @@ -1328,19 +1342,14 @@ pub async fn propose_action( prefetch_contract_keys: vec![], }; - let channel = match data.config.ledger_channel().await { - Ok(channel) => channel, - Err(e) => { - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to connect to ledger API: {e:#}"), - }); - } - }; + let channel = config.ledger_channel().await.map_err(|e| { + SubmitProposalError::internal(format!("Failed to connect to ledger API: {e:#}")) + })?; let mut client = CommandServiceClient::new(channel).max_decoding_message_size(utils::MAX_GRPC_MESSAGE_SIZE); - // Step 1: Create the proposal and get the contract ID back + // Create the proposal and get the contract ID back let mut create_req = tonic::Request::new(SubmitAndWaitForTransactionRequest { commands: Some(commands), transaction_format: None, @@ -1349,29 +1358,93 @@ pub async fn propose_action( .metadata_mut() .insert("authorization", format!("Bearer {token}").parse().unwrap()); - let proposal_cid = match client.submit_and_wait_for_transaction(create_req).await { - Ok(response) => { - // Extract created contract ID from the transaction events - match response.into_inner().transaction.and_then(|tx| { - tx.events.iter().find_map(|event| { - event.event.as_ref().and_then(|e| match e { - canton_proto_rs::com::daml::ledger::api::v2::event::Event::Created( - created, - ) => Some(created.contract_id.clone()), - _ => None, - }) + let response = client + .submit_and_wait_for_transaction(create_req) + .await + .map_err(|e| { + tracing::error!("Failed to create proposal: {e}"); + SubmitProposalError::internal(format!("Failed to create proposal: {e}")) + })?; + + // Extract created contract ID from the transaction events + response + .into_inner() + .transaction + .and_then(|tx| { + tx.events.iter().find_map(|event| { + event.event.as_ref().and_then(|e| match e { + canton_proto_rs::com::daml::ledger::api::v2::event::Event::Created(created) => { + Some(created.contract_id.clone()) + } + _ => None, }) - }) { - Some(cid) => cid, - None => { - return HttpResponse::InternalServerError().json(ErrorResponse { - error: "Proposal created but could not extract contract ID".to_string(), - }); - } - } + }) + }) + .ok_or_else(|| { + SubmitProposalError::internal("Proposal created but could not extract contract ID") + }) +} + +/// Propose a domain governance action (creates a GovernableAction proposal contract) +#[utoipa::path( + tag = "Governance", + request_body = ProposeActionRequest, + responses( + (status = 200, description = "Proposal created", body = MessageResponse), + (status = 400, description = "Bad request", body = ErrorResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), + (status = 403, description = "Forbidden: admin role required", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + (status = 502, description = "Failed to fetch transfer context from registry", body = ErrorResponse), + (status = 503, description = "Governance package not configured on this node", body = ErrorResponse) + ) +)] +#[post("/governance/propose")] +pub async fn propose_action( + http_req: HttpRequest, + data: web::Data, + body: web::Json, +) -> impl Responder { + if let Err(resp) = require_admin(&http_req, data.admin_role.as_deref()) { + return resp; + } + if let Err(msg) = body.proposal.validate() { + return HttpResponse::BadRequest().json(ErrorResponse { + error: msg.to_string(), + }); + } + let party_id = &body.party_id; + let (token, member_party_id) = match get_party_credentials(&data, party_id).await { + Some(creds) => creds, + None => { + return HttpResponse::Unauthorized().json(ErrorResponse { + error: "No credentials configured for party".to_string(), + }); } + }; + + let audit_pool = data.db.clone(); + let audit_summary = crate::server::audit::proposal_summary(&body.proposal); + let audit_details = serde_json::to_string(&*body).unwrap_or_default(); + let audit_party_id = party_id.clone(); + let audit_member = member_party_id.clone(); + + let packages = packages(); + + let proposal_cid = match submit_proposal( + &data.config, + party_id, + &body.rules_contract_id, + &body.proposal, + &token, + &member_party_id, + &packages, + ) + .await + { + Ok(cid) => cid, Err(e) => { - tracing::error!("Failed to create proposal: {e}"); + tracing::error!("Failed to create proposal: {e:#}"); spawn_audit_log( audit_pool, AuditParams { @@ -1382,11 +1455,11 @@ pub async fn propose_action( action_summary: audit_summary, details: audit_details, status: "failed", - error_message: Some(format!("Failed to create proposal: {e}")), + error_message: Some(format!("{e}")), }, ); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("Failed to create proposal: {e}"), + return HttpResponse::build(e.status()).json(ErrorResponse { + error: format!("{e}"), }); } }; @@ -1451,6 +1524,18 @@ pub async fn propose_action( prefetch_contract_keys: vec![], }; + let channel = match data.config.ledger_channel().await { + Ok(channel) => channel, + Err(e) => { + return HttpResponse::InternalServerError().json(ErrorResponse { + error: format!("Failed to connect to ledger API: {e:#}"), + }); + } + }; + + let mut client = + CommandServiceClient::new(channel).max_decoding_message_size(utils::MAX_GRPC_MESSAGE_SIZE); + let mut confirm_req = tonic::Request::new(SubmitAndWaitRequest { commands: Some(confirm_commands), }); @@ -2061,7 +2146,7 @@ async fn get_member_party_id(data: &web::Data, party_id: &CantonId) -> } /// Get token and member_party_id for a party -async fn get_party_credentials( +pub(crate) async fn get_party_credentials( data: &web::Data, party_id: &CantonId, ) -> Option<(String, CantonId)> { @@ -2081,7 +2166,7 @@ async fn get_party_credentials( } /// Get the hardcoded default package config (package IDs are constants, not per-party) -fn packages() -> PackageConfig { +pub(crate) fn packages() -> PackageConfig { default_package_config() } @@ -2090,7 +2175,7 @@ fn packages() -> PackageConfig { // ============================================================================ /// Execute ConfirmAction choice on VaultGovernanceRules contract with structured action -async fn execute_confirm_action( +pub(crate) async fn execute_confirm_action( config: &NodeConfig, request: &ConfirmActionRequest, token: &str, @@ -2673,3 +2758,77 @@ async fn execute_cancel_confirmation( Ok(()) } + +#[cfg(test)] +mod submit_proposal_status_tests { + //! Regression guard for the `propose_action` HTTP status contract. + //! Extracting `submit_proposal` out of the handler once collapsed every + //! failure into a blanket 500; these tests pin distinct failure classes to + //! distinct statuses so that flattening cannot silently return. Both paths + //! return before any ledger/registry network call, so they need no fixtures. + use actix_web::http::StatusCode; + use canton_common::decimal::DamlDecimal; + + use super::*; + use crate::server::types::InstrumentId; + + fn party() -> CantonId { + // `prefix::<68 hex chars>` = a 34-byte all-zero namespace: a well-formed id. + CantonId::parse(&format!("p::{}", "0".repeat(68))).expect("valid canton id") + } + + /// A proposal whose target governance package is not configured is a node + /// provisioning gap, not bad input or a crash: 503, never 500. + #[tokio::test] + async fn unconfigured_package_maps_to_503() { + let mut packages = default_package_config(); + packages.governance_core = None; // `GenericVote` routes to governance_core + + let err = submit_proposal( + &NodeConfig::default(), + &party(), + "", + &ProposalType::GenericVote { + description: "regression guard".to_string(), + }, + "token", + &party(), + &packages, + ) + .await + .expect_err("missing governance_core package must fail"); + + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + /// Malformed request input is 400, never 500. An empty `transfer_factory_cid` + /// routes through registry-context resolution, which parses the instrument + /// admin (here an invalid id) before any network call. + #[tokio::test] + async fn invalid_instrument_admin_maps_to_400() { + let err = submit_proposal( + &NodeConfig::default(), + &party(), + "", + &ProposalType::Transfer { + transfer_factory_cid: String::new(), + expected_admin: party(), + receiver: party(), + amount: DamlDecimal::parse("1.5").unwrap(), + instrument_id: InstrumentId { + admin: "not-a-canton-id".to_string(), + id: "CBTC".to_string(), + }, + input_holding_cids: vec![], + validity_window_hours: None, + }, + "token", + &party(), + &default_package_config(), + ) + .await + .expect_err("invalid instrument admin must fail"); + + assert_eq!(err.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/crates/decman/src/server/handlers/mod.rs b/crates/decman/src/server/handlers/mod.rs index 1d5f7110..431fd554 100644 --- a/crates/decman/src/server/handlers/mod.rs +++ b/crates/decman/src/server/handlers/mod.rs @@ -21,6 +21,10 @@ pub use governance::{ get_transfer_instructions_handler, get_transfer_preapprovals_handler, get_user_services_handler, get_vaults_handler, propose_action, query_contracts_handler, }; +// Crate-internal governance helpers reused by the reward-automation module, +// re-exported here so they are reachable through the private `governance` +// submodule. +pub(crate) use governance::{get_party_credentials, packages}; pub use invitations::{accept_invitation, decline_invitation, get_invitations}; pub use keys::get_key_status; pub use parties::{ diff --git a/crates/decman/src/server/mod.rs b/crates/decman/src/server/mod.rs index 57860ee5..33cc0e11 100644 --- a/crates/decman/src/server/mod.rs +++ b/crates/decman/src/server/mod.rs @@ -15,6 +15,7 @@ mod handlers; mod middleware; mod package_inventory; mod queries; +mod reward_automation; mod transfer_context; mod types; @@ -1022,6 +1023,14 @@ pub async fn start_server( .await; }); + // Background task: CIP-104 Mode A reward-assignment automation. Clone the + // existing `web::Data` (an Arc) so the loop shares the SAME state — + // live party credentials, auth, config — never a fresh AppState. + let reward_automation_state = app_state.clone(); + tokio::spawn(async move { + reward_automation::run_reward_automation_loop(reward_automation_state).await; + }); + // Single peer-job listener: drains the queue and spawns one // `workflow::start_peer` per accepted / retried / resumed invite, so this // node can be a peer in many concurrent workflows at once. diff --git a/crates/decman/src/server/reward_automation.rs b/crates/decman/src/server/reward_automation.rs new file mode 100644 index 00000000..9b0e1090 --- /dev/null +++ b/crates/decman/src/server/reward_automation.rs @@ -0,0 +1,1178 @@ +// Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! CIP-104 Mode A reward-assignment automation (delegation model). +//! +//! A decparty earns traffic-based app rewards as `RewardCouponV2` coupons that +//! carry `beneficiary = None` and expire unclaimed unless automation +//! reassigns them. Under the delegation model, a single threshold governance +//! vote sets the split and the authorized assigners once, into a +//! `CouponReassignmentDelegation`; from then on, any one listed assigner +//! executes each periodic reassignment directly — no per-round voting. This +//! module holds that automation: +//! +//! * [`active_created_records`] — the one shared **decoded** ACS read. Unlike +//! `queries::query_contracts_by_template` (cid + base64 blob, no fields) and +//! `queries::get_contracts` (metadata only), this issues a direct +//! `StateServiceClient` `GetActiveContracts` — modeled on +//! `queries::fetch_proposal_infos` — and returns the decoded create-arguments +//! `Record` (template reads) or the decoded interface-view `Record` +//! (interface reads). +//! * [`active_delegation`] — reads the decparty's active +//! `CouponReassignmentDelegation` (its cid, authorized `assigners`, and how +//! many beneficiaries the split names, to size a chunk); the split's contents +//! are not read here — `Delegation_Assign` enforces them in DAML. +//! * [`unassigned_coupons`] — reads the decparty's unassigned `RewardCoupon` +//! interface views. +//! * [`run_reassign_once`] — one reassign tick: assigns every assignable +//! unassigned coupon via successive chunked `Delegation_Assign` transactions. +//! +//! The pure record decoders, selection and chunk sizing ([`select_assignable`], +//! [`chunk_size`]) are unit-tested here; the gRPC reads and command submission +//! are exercised by the localnet and devnet integration tests. + +use std::collections::HashMap; + +use anyhow::{Context, anyhow}; +use canton_common::decimal::DamlDecimal; +use canton_proto_rs::com::daml::ledger::api::v2::{ + Command, Commands, CumulativeFilter, EventFormat, ExerciseCommand, Filters, + GetActiveContractsRequest, GetLedgerEndRequest, Identifier, InterfaceFilter, Record, + SubmitAndWaitRequest, TemplateFilter, Value, WildcardFilter, command, + command_service_client::CommandServiceClient, cumulative_filter, + get_active_contracts_response::ContractEntry, value, +}; +use chrono::{DateTime, Utc}; + +use crate::{ + canton_id::CantonId, + config::{NodeConfig, PackageConfig}, + utils, +}; + +use std::time::Duration; + +use super::AppState; +use super::action_serializer::{field, make_contract_id, make_list, make_party}; +use super::handlers::{get_party_credentials, packages}; +use super::queries::resolve_contract_package_ref; + +// ============================================================================ +// Record field extraction (mirrors queries.rs `field_*` helpers; the originals +// are module-private, so we follow the same `value::Sum` matching pattern here +// rather than widening their visibility). +// ============================================================================ + +/// Return the decoded `value::Sum` for `label`, if present. +fn record_field<'a>(rec: &'a Record, label: &str) -> Option<&'a value::Sum> { + rec.fields + .iter() + .find(|f| f.label == label) + .and_then(|f| f.value.as_ref()) + .and_then(|v| v.sum.as_ref()) +} + +/// Read a `Party` field and parse it into a [`CantonId`]. +fn field_party_id(rec: &Record, label: &str) -> anyhow::Result { + match record_field(rec, label) { + Some(value::Sum::Party(p)) => p + .parse::() + .with_context(|| format!("field `{label}`: invalid party id `{p}`")), + _ => Err(anyhow!("field `{label}`: expected a Party value")), + } +} + +/// Read a `Numeric` field and parse it into a [`DamlDecimal`] (exact fixed-point). +fn field_decimal(rec: &Record, label: &str) -> anyhow::Result { + match record_field(rec, label) { + Some(value::Sum::Numeric(n)) => DamlDecimal::parse(n) + .map_err(|e| anyhow!("field `{label}`: invalid decimal `{n}`: {e}")), + _ => Err(anyhow!("field `{label}`: expected a Numeric value")), + } +} + +/// Read a DAML `Time` field (encoded as microseconds since the epoch) into a +/// UTC timestamp. +fn field_time(rec: &Record, label: &str) -> anyhow::Result> { + let micros = match record_field(rec, label) { + Some(value::Sum::Timestamp(t)) => *t, + _ => return Err(anyhow!("field `{label}`: expected a Time value")), + }; + DateTime::from_timestamp_micros(micros) + .ok_or_else(|| anyhow!("field `{label}`: timestamp {micros} micros is out of range")) +} + +/// Return true iff `label` is an `Optional` field carrying `None`. +/// +/// A missing field, or a non-optional value, returns false — the caller then +/// treats the contract as *not* unassigned (fail-safe: never assign against a +/// coupon we can't confirm is unassigned). +fn field_optional_is_none(rec: &Record, label: &str) -> bool { + matches!(record_field(rec, label), Some(value::Sum::Optional(opt)) if opt.value.is_none()) +} + +// ============================================================================ +// 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, + test_mode: bool, + package_id: &str, + module: &str, + entity: &str, + interface_view: bool, +) -> anyhow::Result> { + 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) +} + +// ============================================================================ +// CouponReassignmentDelegation (the delegation-model enablement signal) +// ============================================================================ + +/// The active `CouponReassignmentDelegation` for a decparty: its cid, the set +/// of members authorized to execute `Delegation_Assign`, and how many +/// beneficiaries its split names. The split's *contents* are **not** carried +/// here — they live in the on-ledger contract and `Delegation_Assign` enforces +/// them by construction (spec §12), so the Rust side never needs to read them. +/// Only the count is read, to size a chunk: one assign creates +/// `coupons × beneficiaries` contracts (see [`chunk_size`]). +pub(crate) struct ActiveDelegation { + pub cid: String, + /// 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. + pub dso: CantonId, + pub assigners: Vec, + pub beneficiary_count: usize, +} + +/// Return the number of elements in a `List` field. +fn field_list_len(rec: &Record, label: &str) -> anyhow::Result { + 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> { + 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::() + .with_context(|| format!("field `{label}`: invalid party id `{p}`")), + _ => Err(anyhow!("field `{label}`: element is not a Party")), + }) + .collect() +} + +/// Decode a `CouponReassignmentDelegation` create-arguments `Record` into an +/// [`ActiveDelegation`]. Reads only `assigners` — the `split` field is +/// intentionally **not** parsed here; `Delegation_Assign` enforces it in DAML. +fn parse_delegation_record(cid: &str, rec: &Record) -> anyhow::Result { + let beneficiary_count = field_list_len(rec, "split")?; + if beneficiary_count == 0 { + // DAML rejects an empty split at create, so this means a decode problem + // rather than a real contract. Refuse rather than size a chunk from it. + return Err(anyhow!("field `split`: delegation names no beneficiaries")); + } + Ok(ActiveDelegation { + cid: cid.to_string(), + dso: field_party_id(rec, "dso")?, + assigners: field_party_list(rec, "assigners")?, + beneficiary_count, + }) +} + +/// 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 +/// delegation for the same decparty is refused rather than guessed at. This +/// is the reassign loop's enablement + assigners source. +pub(crate) async fn active_delegation( + config: &NodeConfig, + packages: &PackageConfig, + test_mode: bool, + decparty: &CantonId, + token: &str, +) -> anyhow::Result> { + let Some(package_id) = packages.governance_rewards.as_deref() else { + return Ok(None); + }; + + let records = active_created_records( + config, + decparty, + Some(token.to_string()), + test_mode, + package_id, + "Governance.Rewards.CouponReassignmentDelegation", + "CouponReassignmentDelegation", + false, + ) + .await?; + + // Defend the keyless-singleton invariant: keep only delegations whose + // `decparty` field is this decparty. + 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" + )) + } + } +} + +// ============================================================================ +// Unassigned reward coupons +// ============================================================================ + +/// A decparty's unassigned reward coupon (`RewardCoupon` interface view). +pub(crate) struct CouponInfo { + pub cid: String, + /// Currently unused — the batching logic keys off `cid` + `expires_at` + /// only. Retained for future operator logging / devnet IT assertions. + #[allow(dead_code)] + pub provider: CantonId, + /// See `provider` — currently unused, retained for the same reason. + #[allow(dead_code)] + pub amount: DamlDecimal, + pub expires_at: DateTime, +} + +/// Read every reward coupon for `decparty` that is still unassigned +/// (`provider == decparty`, `beneficiary` is `None`, and `dso` is the +/// delegation's DSO — see [`parse_unassigned_coupon`]). +/// +/// Uses the `RewardCoupon` interface view (`#splice-api-reward-assignment-v1`); +/// on devnet the concrete implementer is `RewardCouponV2`. +pub(crate) async fn unassigned_coupons( + config: &NodeConfig, + decparty: &CantonId, + dso: &CantonId, + token: Option, + test_mode: bool, + packages: &PackageConfig, +) -> anyhow::Result> { + let _ = packages; // the interface is resolved by name-alias, not PackageConfig. + let records = active_created_records( + config, + decparty, + token, + test_mode, + "#splice-api-reward-assignment-v1", + "Splice.Api.RewardAssignmentV1", + "RewardCoupon", + true, + ) + .await?; + + let mut out = Vec::new(); + for (cid, rec) in records { + if let Some(coupon) = parse_unassigned_coupon(&cid, &rec, decparty, dso)? { + out.push(coupon); + } + } + Ok(out) +} + +/// Decode one `RewardCoupon` interface-view record into a [`CouponInfo`], or +/// `None` when it is not an unassigned coupon for `decparty`. Fail-safe: a +/// coupon whose `provider` differs, or whose `beneficiary` is set (or cannot be +/// confirmed absent), is skipped — never assigned against. Returns `Err` only +/// when a coupon that *does* match cannot be decoded (bad amount/expiry). +fn parse_unassigned_coupon( + cid: &str, + rec: &Record, + decparty: &CantonId, + dso: &CantonId, +) -> anyhow::Result> { + let provider = field_party_id(rec, "provider")?; + if &provider != decparty { + return Ok(None); + } + // `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. + if field_party_id(rec, "dso")? != *dso { + return Ok(None); + } + if !field_optional_is_none(rec, "beneficiary") { + return Ok(None); + } + Ok(Some(CouponInfo { + cid: cid.to_string(), + provider, + amount: field_decimal(rec, "amount")?, + expires_at: field_time(rec, "expiresAt")?, + })) +} + +// ============================================================================ +// Coupon batch selection +// ============================================================================ + +/// A coupon is assignable (pure) iff more than `expiry_margin` remains before +/// it expires. +/// +/// The margin exists to keep a coupon that is about to vanish out of a chunk: +/// `Delegation_Assign` is all-or-nothing, so a coupon expiring between the ACS +/// read and the commit fails the whole chunk. It is deliberately **not** a +/// reserve of minting time for the beneficiary — withholding a coupon +/// guarantees nobody ever mints it, whereas assigning it late still lets the +/// beneficiary try, and the per-beneficiary coupons inherit `expiresAt`. For +/// the same reason there is no minimum-age gate: assignment neither consumes +/// the coupon nor shortens it, so assigning as early as we see it leaves the +/// beneficiary the most time to mint. +fn is_assignable(c: &CouponInfo, now: DateTime, expiry_margin: chrono::Duration) -> bool { + c.expires_at - now >= expiry_margin +} + +/// Every assignable coupon (pure), ordered most-urgent-first (ascending +/// `expiresAt`) so that under any partial failure the coupons closest to expiry +/// are assigned first. The whole set is returned — it is *not* truncated to a +/// batch size, because a tick assigns all of it in successive chunked +/// transactions (see [`chunk_size`] and [`run_reassign_once`]); the chunk size +/// bounds one transaction, not a tick's work. +pub(crate) fn select_assignable( + coupons: &[CouponInfo], + now: DateTime, + expiry_margin: chrono::Duration, +) -> Vec { + let mut selected: Vec<&CouponInfo> = coupons + .iter() + .filter(|c| is_assignable(c, now, expiry_margin)) + .collect(); + selected.sort_by_key(|c| c.expires_at); + selected.into_iter().map(|c| c.cid.clone()).collect() +} + +/// How many coupons one `Delegation_Assign` may carry (pure). +/// +/// The binding constraint is transaction size, and one assign creates +/// `coupons × beneficiaries` contracts — so the cap is expressed in *output +/// creates* and the coupon count is derived from the delegation's beneficiary +/// count. A fixed coupon count would be `beneficiary_count`-times looser for a +/// wide split than a narrow one. Never returns 0, so a tick always makes +/// progress even with an implausibly wide split. +pub(crate) fn chunk_size(max_creates: usize, beneficiary_count: usize) -> usize { + (max_creates / beneficiary_count.max(1)).max(1) +} + +// ============================================================================ +// Delegation-model per-round assigner (`Delegation_Assign`) +// ============================================================================ + +/// Build the `Delegation_Assign` choice argument (pure): fields +/// `assigner, primaryCoupon, additionalCoupons`, in that order. +fn build_delegation_assign_arg( + assigner: &CantonId, + primary: &str, + additional: &[String], +) -> Record { + Record { + record_id: None, + fields: vec![ + field("assigner", make_party(assigner)), + field("primaryCoupon", make_contract_id(primary)), + field( + "additionalCoupons", + make_list(additional.iter().map(|c| make_contract_id(c)).collect()), + ), + ], + } +} + +/// Build the `Identifier` for the `CouponReassignmentDelegation` template +/// (pure). The DAML template lives in module `Governance.Rewards. +/// CouponReassignmentDelegation` (not the shorter `Governance.Rewards`) — the +/// Ledger API `module_name` must be the full module path or the exercise +/// resolves against the wrong (non-existent) template. +fn delegation_template_id(package_id: String) -> Identifier { + Identifier { + package_id, + module_name: "Governance.Rewards.CouponReassignmentDelegation".to_string(), + entity_name: "CouponReassignmentDelegation".to_string(), + } +} + +/// Exercise `Delegation_Assign` as a plain ledger command (no governance +/// round). Adapted from `execute_confirm_action` +/// (`handlers/governance.rs:2107`); the differences are the target contract +/// (the delegation cid), the choice (`Delegation_Assign`), the template +/// (`Governance.Rewards.CouponReassignmentDelegation`), and `act_as = +/// [assigner]` / `read_as = [decparty]` (co-hosting, spec §4.6). +#[allow(clippy::too_many_arguments)] +pub(crate) async fn submit_delegation_assign( + config: &NodeConfig, + decparty: &CantonId, + assigner: &CantonId, + token: &str, + delegation_cid: &str, + primary: &str, + additional: &[String], + packages: &PackageConfig, +) -> anyhow::Result<()> { + let choice_argument = Value { + sum: Some(value::Sum::Record(build_delegation_assign_arg( + assigner, primary, additional, + ))), + }; + let fallback = packages + .governance_rewards + .as_deref() + .context("governance_rewards package not configured")?; + // The delegation may live under an older package ref — resolve its actual + // one (same as `execute_confirm_action`, governance.rs:2177-2184). + let package_id = resolve_contract_package_ref( + config, + decparty, + Some(token.to_string()), + delegation_cid, + fallback, + ) + .await; + let template_id = delegation_template_id(package_id); + // `ledger_channel` (not a raw `Channel::from_shared`) so this respects the + // configured Canton TLS/mTLS settings — the automation's own reads go through + // it via `create_state_client`, and an assign that bypassed it would be the + // one path unable to talk to a TLS-enabled ledger. + let channel = config.ledger_channel().await?; + let mut client = + CommandServiceClient::new(channel).max_decoding_message_size(utils::MAX_GRPC_MESSAGE_SIZE); + let cmd = Command { + command: Some(command::Command::Exercise(ExerciseCommand { + template_id: Some(template_id), + contract_id: delegation_cid.to_string(), + choice: "Delegation_Assign".to_string(), + choice_argument: Some(choice_argument), + })), + }; + // act_as = [assigner], read_as = [decparty]. Remaining fields mirror + // `execute_confirm_action` (governance.rs:2203-2217). + let commands = Commands { + workflow_id: String::new(), + user_id: String::new(), + command_id: uuid::Uuid::new_v4().to_string(), + commands: vec![cmd], + deduplication_period: None, + min_ledger_time_abs: None, + min_ledger_time_rel: None, + act_as: vec![assigner.to_string()], + read_as: vec![decparty.to_string()], + submission_id: String::new(), + disclosed_contracts: vec![], + synchronizer_id: String::new(), + package_id_selection_preference: vec![], + prefetch_contract_keys: vec![], + }; + let mut req = tonic::Request::new(SubmitAndWaitRequest { + commands: Some(commands), + }); + req.metadata_mut() + .insert("authorization", format!("Bearer {token}").parse().unwrap()); + client.submit_and_wait(req).await?; // an assign needs no created-cid readback + Ok(()) +} + +/// One reassign tick for a decparty under the delegation model: read the +/// unassigned coupons and assign **all** the assignable ones, in successive +/// chunked `Delegation_Assign` transactions. Nothing assignable is a no-op. +/// +/// A tick drains the whole set rather than assigning one chunk and waiting for +/// the next tick, so throughput does not depend on the tick interval: the +/// interval is a latency/cost knob, not a safety-critical one. The chunk bounds +/// one *transaction* (spec §9/§11; see [`chunk_size`]). +/// +/// A failed chunk ends the tick (see [`drain_assignable`]). +/// +/// The create budget and the expiry margin come from [`NodeConfig`] so they can +/// be tuned against a live ledger without a rebuild. +pub(crate) async fn run_reassign_once( + config: &NodeConfig, + decparty: &CantonId, + assigner: &CantonId, + token: &str, + delegation: &ActiveDelegation, + test_mode: bool, + packages: &PackageConfig, +) -> anyhow::Result<()> { + let expiry_margin = + chrono::Duration::seconds(config.reward_min_expiry_margin_secs.min(i64::MAX as u64) as i64); + + let coupons = unassigned_coupons( + config, + decparty, + &delegation.dso, + Some(token.to_string()), + test_mode, + packages, + ) + .await?; + let assignable = select_assignable(&coupons, Utc::now(), expiry_margin); + if assignable.is_empty() { + return Ok(()); // nothing assignable -> no-op + } + + let assigned = drain_assignable( + &assignable, + chunk_size(config.reward_max_creates, delegation.beneficiary_count), + decparty, + |primary, additional| { + submit_delegation_assign( + config, + decparty, + assigner, + token, + &delegation.cid, + primary, + additional, + packages, + ) + }, + ) + .await; + if assigned > 0 { + tracing::info!(%decparty, %assigner, count = assigned, "reassigned coupon batch"); + } + Ok(()) +} + +/// Assign `assignable` in `chunk`-sized batches via `submit`, returning how many +/// coupons were assigned. The submit step is injected so the chunking and the +/// failure rule are unit-testable without a ledger. +/// +/// **A failed chunk ends the drain.** Every assign failure seen in practice +/// means this node's view is stale — another assigner committed first +/// (`CONTRACT_NOT_FOUND`, `LOCAL_VERDICT_LOCKED_CONTRACTS`) or the coupon +/// expired — and the only cure is a fresh read, which the next tick performs. +/// Retrying the chunk smaller cannot help, because a smaller chunk is not a +/// newer view; and skipping the coupon to continue would grind through failure +/// after failure, since an assigner that took the first coupon has very likely +/// taken the rest of the batch too. Ending the tick costs at most one interval +/// of latency for the coupons left behind, and they keep their full TTL. +/// +/// 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. +async fn drain_assignable<'a, F, Fut>( + assignable: &'a [String], + chunk: usize, + decparty: &CantonId, + mut submit: F, +) -> usize +where + F: FnMut(&'a str, &'a [String]) -> Fut, + Fut: std::future::Future>, +{ + let size = chunk.max(1); + let mut offset = 0; + let mut assigned = 0; + while offset < assignable.len() { + let end = (offset + size).min(assignable.len()); + let (primary, additional) = assignable[offset..end] + .split_first() + .expect("offset < len, so the chunk is non-empty"); + if let Err(e) = submit(primary, additional).await { + tracing::warn!( + %decparty, + error = %e, + coupon = %primary, + assigned, + remaining = assignable.len() - offset, + "assign chunk failed; ending tick to re-read the ledger" + ); + break; + } + assigned += end - offset; + offset = end; + } + assigned +} + +// ============================================================================ +// Background loop + registration +// ============================================================================ + +/// Per-node background loop: every `reward_automation_interval_secs`, read the +/// active `CouponReassignmentDelegation` for each decparty this node holds +/// credentials for, and — if this node's member party is a listed assigner — +/// reassign its due coupons via [`run_reassign_once`]. Enablement is +/// on-ledger — a decparty with no active delegation is skipped. +pub(crate) async fn run_reward_automation_loop(data: actix_web::web::Data) { + // `tokio::time::interval` panics on a zero period, which would silently kill + // this background task; clamp a misconfigured 0 to 1s and warn. + let interval_secs = data.config.reward_automation_interval_secs; + if interval_secs == 0 { + tracing::warn!("reward_automation_interval_secs is 0; using 1s instead"); + } + let mut ticker = tokio::time::interval(Duration::from_secs(interval_secs.max(1))); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + ticker.tick().await; + let parties: Vec = data + .party_credentials + .read() + .await + .iter() + .map(|p| p.dec_party_id.clone()) + .collect(); + for decparty in parties { + if let Err(e) = run_once_for_party(&data, &decparty).await { + tracing::warn!(%decparty, error = %e, "reward automation tick failed"); + } + } + } +} + +/// One reassign pass for a single decparty under the delegation model. No-op +/// unless the decparty has an active `CouponReassignmentDelegation` (the +/// enablement signal) naming this node's member party as an assigner. +async fn run_once_for_party( + data: &actix_web::web::Data, + decparty: &CantonId, +) -> anyhow::Result<()> { + let pkgs = packages(); + let Some((token, member)) = get_party_credentials(data, decparty).await else { + return Ok(()); + }; + // Enablement: exactly one active delegation. None => off (no-op). >1 => Err (refuse+alert). + let Some(delegation) = + active_delegation(&data.config, &pkgs, data.test_mode, decparty, &token).await? + else { + return Ok(()); + }; + // This node must be a listed assigner, else it cannot reassign (spec §9, §11). + if !delegation.assigners.contains(&member) { + tracing::debug!(%decparty, %member, "node not an assigner on the delegation — skipping"); + return Ok(()); + } + run_reassign_once( + &data.config, + decparty, + &member, + &token, + &delegation, + data.test_mode, + &pkgs, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::super::types::RewardBeneficiary; + use super::*; + use canton_proto_rs::com::daml::ledger::api::v2::{List, Optional, RecordField, Value}; + + fn value(sum: value::Sum) -> Value { + Value { sum: Some(sum) } + } + + fn field(label: &str, sum: value::Sum) -> RecordField { + RecordField { + label: label.to_string(), + value: Some(value(sum)), + } + } + + fn record(fields: Vec) -> Record { + Record { + record_id: None, + fields, + } + } + + fn party(p: &str) -> value::Sum { + value::Sum::Party(p.to_string()) + } + + fn numeric(n: &str) -> value::Sum { + value::Sum::Numeric(n.to_string()) + } + + fn optional_none() -> value::Sum { + value::Sum::Optional(Box::new(Optional { value: None })) + } + + fn optional_some_party(p: &str) -> value::Sum { + value::Sum::Optional(Box::new(Optional { + value: Some(Box::new(value(party(p)))), + })) + } + + fn timestamp(micros: i64) -> value::Sum { + value::Sum::Timestamp(micros) + } + + fn beneficiary_record(p: &str, pct: &str) -> Value { + value(value::Sum::Record(record(vec![ + field("beneficiary", party(p)), + field("percentage", numeric(pct)), + ]))) + } + + /// Test-only helper mirroring `server::types` tests: builds a + /// [`RewardBeneficiary`] from a Canton-ID prefix + a decimal percentage + /// string. A fixed valid namespace keeps party ids parseable; distinct + /// prefixes yield distinct parties. + fn rb(prefix: &str, pct: &str) -> RewardBeneficiary { + let ns = "1220c4010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5892"; + RewardBeneficiary { + beneficiary: CantonId::parse(&format!("{prefix}::{ns}")).expect("valid canton id"), + percentage: pct.parse().expect("valid decimal"), + } + } + + // Valid Canton party ids: `prefix::` where the namespace is a + // 34-byte (68-hex-char) SHA-256 multihash (`1220` + 64 hex chars). + const GOV: &str = "gov::12200000000000000000000000000000000000000000000000000000000000000000"; + const ALICE: &str = + "alice::1220aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const BOB: &str = "bob::1220bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + #[test] + fn parse_delegation_record_reads_assigners_and_split() { + // List values: `value::Sum::List(List { elements: vec![Value, ..] })` — same as the + // existing parse_split_record test. `party(..)` returns value::Sum, so wrap each with + // `value(..)`; `beneficiary_record(..)` already returns a Value. `field(label, sum)` + // takes a value::Sum, so pass `value::Sum::List(..)` directly. + let rec = record(vec![ + field("decparty", party(GOV)), + field("dso", party(GOV)), + field( + "assigners", + value::Sum::List(List { + elements: vec![value(party(ALICE)), value(party(BOB))], + }), + ), + field( + "split", + value::Sum::List(List { + elements: vec![ + beneficiary_record(ALICE, "0.8"), + beneficiary_record(BOB, "0.2"), + ], + }), + ), + ]); + let d = parse_delegation_record("00del", &rec).unwrap(); + assert_eq!(d.cid, "00del"); + assert_eq!(d.assigners.len(), 2); + // Only the split's *length* is read, to size a chunk; its contents stay + // DAML-enforced and are never used to build a command. + assert_eq!(d.beneficiary_count, 2); + assert_eq!(d.dso.to_string(), GOV); + } + + #[test] + fn parse_delegation_record_refuses_an_empty_split() { + // DAML rejects an empty split at create, so this is a decode problem; + // accepting it would size chunks off a meaningless beneficiary count. + let rec = record(vec![ + field("dso", party(GOV)), + field( + "assigners", + value::Sum::List(List { + elements: vec![value(party(ALICE))], + }), + ), + field("split", value::Sum::List(List { elements: vec![] })), + ]); + assert!(parse_delegation_record("00del", &rec).is_err()); + } + + // ---- unassigned-coupon fail-safe filter (parse_unassigned_coupon) ------- + + #[test] + fn parse_unassigned_coupon_keeps_only_unassigned_for_decparty() { + let alice = CantonId::parse(ALICE).unwrap(); + let bob = CantonId::parse(BOB).unwrap(); + let dso = CantonId::parse(GOV).unwrap(); + + let unassigned = record(vec![ + field("dso", party(GOV)), + field("provider", party(ALICE)), + field("beneficiary", optional_none()), + field("amount", numeric("100.0")), + field("expiresAt", timestamp(1_700_000_000_000_000)), + ]); + + // provider == decparty, beneficiary is None, dso matches -> kept. + let got = parse_unassigned_coupon("00c1", &unassigned, &alice, &dso).unwrap(); + let coupon = got.expect("unassigned coupon for the decparty is kept"); + assert_eq!(coupon.cid, "00c1"); + assert_eq!(coupon.amount, "100.0".parse().unwrap()); + + // provider != decparty -> skipped (fail-safe). + assert!( + parse_unassigned_coupon("00c1", &unassigned, &bob, &dso) + .unwrap() + .is_none() + ); + + // beneficiary already set -> skipped (never reassign an assigned coupon). + let assigned = record(vec![ + field("dso", party(GOV)), + field("provider", party(ALICE)), + field("beneficiary", optional_some_party(BOB)), + field("amount", numeric("100.0")), + field("expiresAt", timestamp(1_700_000_000_000_000)), + ]); + assert!( + parse_unassigned_coupon("00c2", &assigned, &alice, &dso) + .unwrap() + .is_none() + ); + } + + #[test] + fn parse_unassigned_coupon_rejects_a_foreign_dso() { + // `dso` is RewardCouponV2's only signatory, so any party can mint one + // naming itself dso and this decparty as provider. Letting it into a + // batch is a denial of service: sorted most-urgent-first it becomes the + // primary, splice fetches the genuine coupons with the primary's dso, + // the chunk is rejected, and the tick ends having assigned nothing. + let alice = CantonId::parse(ALICE).unwrap(); + let real_dso = CantonId::parse(GOV).unwrap(); + + let planted = record(vec![ + field("dso", party(BOB)), // minted by BOB as its own dso + field("provider", party(ALICE)), + field("beneficiary", optional_none()), + field("amount", numeric("100.0")), + field("expiresAt", timestamp(1_700_000_000_000_000)), + ]); + assert!( + parse_unassigned_coupon("00bad", &planted, &alice, &real_dso) + .unwrap() + .is_none(), + "a coupon from another dso must never enter the batch" + ); + } + + // ---- selection + chunk sizing ------------------------------------------- + + fn dt(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s) + .expect("valid rfc3339") + .with_timezone(&Utc) + } + + fn coupon(id: &str, expires: &str) -> CouponInfo { + CouponInfo { + cid: id.to_string(), + provider: CantonId::parse(ALICE).expect("valid canton id"), + amount: "1".parse().expect("valid decimal"), + expires_at: dt(expires), + } + } + + #[test] + fn select_assignable_keeps_margin_and_orders_most_urgent_first() { + let now = dt("2026-07-20T12:00:00Z"); + let coupons = vec![ + // ~35h to expiry -> freshly minted, but nothing is gained by + // holding it back -> included. + coupon("young", "2026-07-21T23:00:00Z"), + // 8h to expiry -> included. + coupon("mid", "2026-07-20T20:00:00Z"), + // 30s to expiry -> may vanish mid-submission and fail the whole + // chunk -> excluded. + coupon("expiring", "2026-07-20T12:00:30Z"), + ]; + let got = select_assignable(&coupons, now, chrono::Duration::minutes(2)); + assert_eq!(got, vec!["mid".to_string(), "young".to_string()]); + } + + #[test] + fn select_assignable_keeps_a_coupon_the_beneficiary_may_not_have_time_to_mint() { + // The margin guards our own submission, not the beneficiary's minting + // window: withholding a coupon guarantees nobody mints it, while + // assigning it late still lets the beneficiary try. A coupon 30 min from + // expiry is well past any minting comfort but safely assignable. + let now = dt("2026-07-20T12:00:00Z"); + let coupons = vec![coupon("late", "2026-07-20T12:30:00Z")]; + let got = select_assignable(&coupons, now, chrono::Duration::minutes(2)); + assert_eq!(got, vec!["late".to_string()]); + } + + #[test] + fn select_assignable_does_not_truncate() { + // A tick drains the whole set in chunks, so selection returns all of it; + // bounding a transaction is chunk_size's job, not selection's. + let now = dt("2026-07-20T12:00:00Z"); + let coupons: Vec = (0..500) + .map(|i| coupon(&format!("c{i}"), "2026-07-20T20:00:00Z")) + .collect(); + assert_eq!( + select_assignable(&coupons, now, chrono::Duration::hours(2)).len(), + 500 + ); + } + + #[test] + fn chunk_size_scales_with_beneficiary_count() { + // The cap is on output creates, so a wider split means fewer coupons per + // transaction — a fixed coupon count would be 10x looser at 20 + // beneficiaries than at 2. + assert_eq!(chunk_size(100, 2), 50); + assert_eq!(chunk_size(100, 20), 5); + assert_eq!(chunk_size(100, 1), 100); + } + + // ---- drain loop (drain_assignable) -------------------------------------- + + fn cids(n: usize) -> Vec { + (0..n).map(|i| format!("c{i}")).collect() + } + + fn alice() -> CantonId { + CantonId::parse(ALICE).expect("valid canton id") + } + + /// Sizes of the chunks a drain submitted, in order. + type ChunkSizes = std::rc::Rc>>; + + /// Record each submitted chunk's size, answering with `outcomes` in order + /// (`true` = Ok). Runs out of outcomes => Ok. + fn recorder( + outcomes: Vec, + ) -> ( + ChunkSizes, + impl FnMut(&str, &[String]) -> std::future::Ready>, + ) { + let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let log = seen.clone(); + let mut i = 0; + let f = move |_p: &str, additional: &[String]| { + log.borrow_mut().push(additional.len() + 1); + let ok = outcomes.get(i).copied().unwrap_or(true); + i += 1; + std::future::ready(if ok { + Ok(()) + } else { + Err(anyhow!("submission rejected")) + }) + }; + (seen, f) + } + + #[tokio::test] + async fn drain_assigns_the_whole_set_in_one_pass() { + // The point of draining: 120 coupons at a chunk of 50 is three + // transactions in ONE tick, not one transaction and a wait. + let all = cids(120); + let (seen, submit) = recorder(vec![]); + let assigned = drain_assignable(&all, 50, &alice(), submit).await; + assert_eq!(assigned, 120); + assert_eq!(*seen.borrow(), vec![50, 50, 20]); + } + + #[tokio::test] + async fn drain_ends_the_tick_on_a_failed_chunk() { + // A failure means the view is stale, and only a fresh read fixes that. + // Exactly one attempt: no retry at a smaller size (a smaller chunk is + // not a newer view) and no skip-and-continue (an assigner that took the + // first coupon has most likely taken the rest). + let all = cids(120); + let (seen, submit) = recorder(vec![false]); + let assigned = drain_assignable(&all, 50, &alice(), submit).await; + assert_eq!(assigned, 0); + assert_eq!(*seen.borrow(), vec![50], "one attempt, then end the tick"); + } + + #[tokio::test] + async fn drain_keeps_the_chunks_it_already_committed() { + // Ending the tick must not discard earlier successes: the first chunk is + // assigned, the second fails, and the remainder waits for the next tick + // with its full TTL intact. + let all = cids(120); + let (seen, submit) = recorder(vec![true, false]); + let assigned = drain_assignable(&all, 50, &alice(), submit).await; + assert_eq!(assigned, 50); + assert_eq!(*seen.borrow(), vec![50, 50]); + } + + #[tokio::test] + async fn drain_attempts_once_when_the_set_is_smaller_than_the_chunk() { + // One assignable coupon against a chunk of 50 is the shape devnet + // contention actually takes. It must cost a single submission. + let all = cids(1); + let (seen, submit) = recorder(vec![false]); + let assigned = drain_assignable(&all, 50, &alice(), submit).await; + assert_eq!(assigned, 0); + assert_eq!(*seen.borrow(), vec![1]); + } + + #[test] + fn chunk_size_never_stalls_a_tick() { + // A split wider than the create budget still yields a 1-coupon chunk + // rather than 0, which would loop forever making no progress. + assert_eq!(chunk_size(100, 500), 1); + assert_eq!(chunk_size(0, 2), 1); + // A malformed 0 count must not divide by zero. + assert_eq!(chunk_size(100, 0), 100); + } + + #[test] + fn build_delegation_assign_arg_shape() { + // rb(..).beneficiary yields a CantonId (this module has no canton_id helper); + // rb takes a bare prefix and appends a fixed namespace itself. + let rec = + build_delegation_assign_arg(&rb("m1", "1.0").beneficiary, "00c1", &["00c2".into()]); + let labels: Vec<&str> = rec.fields.iter().map(|f| f.label.as_str()).collect(); + assert_eq!(labels, ["assigner", "primaryCoupon", "additionalCoupons"]); + } + + #[test] + fn delegation_template_id_uses_full_module_path() { + // The DAML template lives in `Governance.Rewards.CouponReassignmentDelegation`, + // not the shorter `Governance.Rewards` — a truncated module_name makes the + // Ledger API resolve the exercise against a non-existent template. + let id = delegation_template_id("pkg123".to_string()); + assert_eq!(id.package_id, "pkg123"); + assert_eq!( + id.module_name, + "Governance.Rewards.CouponReassignmentDelegation" + ); + assert_eq!(id.entity_name, "CouponReassignmentDelegation"); + } +} diff --git a/crates/decman/src/server/types.rs b/crates/decman/src/server/types.rs index 8b4bcf0b..97e3469c 100644 --- a/crates/decman/src/server/types.rs +++ b/crates/decman/src/server/types.rs @@ -337,6 +337,16 @@ pub struct AppRewardBeneficiary { pub weight: DamlDecimal, } +/// A CIP-104 reward-coupon beneficiary assignment. +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] +pub struct RewardBeneficiary { + pub beneficiary: CantonId, + #[schema(value_type = String)] + #[cfg_attr(feature = "typegen", ts(type = "string"))] + pub percentage: DamlDecimal, +} + /// Featured App Right configuration #[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] @@ -630,6 +640,21 @@ pub enum ProposalType { #[serde(default)] provider_app_reward_beneficiaries: Option>, }, + /// Create (or replace) the decparty's on-ledger CouponReassignmentDelegation. + /// `prior_delegation` is the cid of the delegation being replaced (None for the first). + SetupCouponReassignmentDelegation { + /// 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. + dso: CantonId, + assigners: Vec, + new_beneficiaries: Vec, + #[serde(default)] + prior_delegation: Option, + }, + /// Revoke (archive) the decparty's CouponReassignmentDelegation. + RevokeCouponReassignmentDelegation { delegation: String }, /// Toggle result-contract emission on a `RegistrarService`. SetEnableResultContracts { registrar_service_cid: String, @@ -779,11 +804,85 @@ impl ProposalType { provider_app_reward_beneficiaries: Some(beneficiaries), .. } => validate_beneficiary_weights(beneficiaries), + ProposalType::SetupCouponReassignmentDelegation { + dso, + assigners, + new_beneficiaries, + .. + } => { + if assigners.is_empty() { + return Err("assigners must not be empty".to_string()); + } + let mut seen = std::collections::HashSet::new(); + for a in assigners { + if !seen.insert(a) { + return Err(format!("duplicate assigner not allowed: {a}")); + } + } + if assigners.contains(dso) { + return Err("the dso must not be an assigner".to_string()); + } + validate_reward_beneficiaries(new_beneficiaries) + } + ProposalType::RevokeCouponReassignmentDelegation { delegation } => { + if delegation.trim().is_empty() { + return Err("delegation must not be empty".to_string()); + } + Ok(()) + } _ => Ok(()), } } } +/// Validates a `new_beneficiaries` list (e.g. +/// `SetupCouponReassignmentDelegation::new_beneficiaries`): non-empty, +/// <= 20 entries, no duplicate beneficiary, each percentage in (0.0, 1.0], +/// summing to exactly 1.0. +/// +/// The uniqueness rule mirrors the on-ledger `RewardCoupon_AssignBeneficiaries` +/// impl (`require "Beneficaries are unique"`); catching it here means a +/// duplicated split is rejected at propose time rather than passing the vote +/// and then failing every `Delegation_Assign`, which would leave a permanently +/// unusable delegation. +/// +/// `DamlDecimal` addition is exact (no float rounding), so an exact `==` +/// against `1.0` is sufficient here — no epsilon tolerance is needed. +fn validate_reward_beneficiaries(beneficiaries: &[RewardBeneficiary]) -> Result<(), String> { + if beneficiaries.is_empty() { + return Err("new_beneficiaries must not be empty".to_string()); + } + if beneficiaries.len() > 20 { + return Err("at most 20 beneficiaries per coupon".to_string()); + } + let zero = "0" + .parse::() + .expect("'0' is a valid DamlDecimal"); + let one: DamlDecimal = "1".parse().expect("'1' is a valid DamlDecimal"); + let mut seen = std::collections::HashSet::new(); + for b in beneficiaries { + if b.percentage.value() <= zero.value() || b.percentage.value() > one.value() { + return Err(format!( + "each percentage must be in (0.0, 1.0], got {}", + b.percentage + )); + } + if !seen.insert(&b.beneficiary) { + return Err(format!( + "duplicate beneficiary not allowed: {}", + b.beneficiary + )); + } + } + let sum: DamlDecimal = beneficiaries.iter().map(|b| b.percentage).sum(); + if sum != one { + return Err(format!( + "reward beneficiary percentages must sum to exactly 1.0, got {sum}" + )); + } + Ok(()) +} + /// Request to propose a governance domain action (creates proposal contract) #[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] #[cfg_attr(feature = "typegen", derive(ts_rs::TS), ts(optional_fields))] @@ -1433,4 +1532,89 @@ mod tests { assert!(mk("1.0", Some(48)).validate().is_ok()); assert!(mk("1.0", Some(0)).validate().is_err()); } + + /// Test-only helper: builds a `CantonId` with a fixed valid namespace so + /// tests can vary just the prefix. + fn cid(prefix: &str) -> CantonId { + let ns = "1220c4010d6883f367c7f45d55b2449501620130f9b21e96379f17dea455ac7a5892"; + CantonId::parse(&format!("{prefix}::{ns}")).unwrap() + } + + /// Test-only helper: builds a `RewardBeneficiary` from a Canton-ID prefix + /// and a decimal percentage string. + fn rb(prefix: &str, pct: &str) -> RewardBeneficiary { + RewardBeneficiary { + beneficiary: cid(prefix), + percentage: pct.parse().expect("valid decimal"), + } + } + + #[test] + fn setup_delegation_validate() { + // Reuse the `rb` helper from the neighboring set_reward_split_validate + // test; `rb(..).beneficiary` yields a CantonId (there is no dedicated + // party-id helper). Note: `rb`'s prefix is combined with a fixed + // namespace via `cid()`, so the prefix must be a plain string (no + // embedded "::") -- unlike the brief's example. + let execs = vec![rb("m1", "1.0").beneficiary, rb("m2", "1.0").beneficiary]; + let ok = ProposalType::SetupCouponReassignmentDelegation { + dso: rb("dso", "1.0").beneficiary, + assigners: execs.clone(), + new_beneficiaries: vec![rb("a", "0.8"), rb("b", "0.2")], + prior_delegation: None, + }; + assert!(ok.validate().is_ok()); + let no_exec = ProposalType::SetupCouponReassignmentDelegation { + dso: rb("dso", "1.0").beneficiary, + assigners: vec![], + new_beneficiaries: vec![rb("a", "1.0")], + prior_delegation: None, + }; + assert!(no_exec.validate().is_err()); + let bad_sum = ProposalType::SetupCouponReassignmentDelegation { + dso: rb("dso", "1.0").beneficiary, + assigners: execs, + new_beneficiaries: vec![rb("a", "0.5")], + prior_delegation: None, + }; + assert!(bad_sum.validate().is_err()); + let revoke = ProposalType::RevokeCouponReassignmentDelegation { + delegation: "00abc".into(), + }; + assert!(revoke.validate().is_ok()); + // An empty delegation cid is rejected at the boundary (not left to fail + // only at ledger submission). + let revoke_empty = ProposalType::RevokeCouponReassignmentDelegation { + delegation: " ".into(), + }; + assert!(revoke_empty.validate().is_err()); + } + + #[test] + fn validate_reward_beneficiaries_edge_cases() { + // Empty is rejected. + assert!(validate_reward_beneficiaries(&[]).is_err()); + + // Per-percentage bound is (0.0, 1.0]: 0.0, negative, and > 1.0 all reject. + assert!(validate_reward_beneficiaries(&[rb("a", "0.0"), rb("b", "1.0")]).is_err()); + assert!(validate_reward_beneficiaries(&[rb("a", "-0.5"), rb("b", "1.5")]).is_err()); + assert!(validate_reward_beneficiaries(&[rb("a", "1.5")]).is_err()); + + // A single 1.0 (upper bound inclusive) is accepted. + assert!(validate_reward_beneficiaries(&[rb("a", "1.0")]).is_ok()); + + // Duplicate beneficiary is rejected even when percentages are otherwise valid. + assert!(validate_reward_beneficiaries(&[rb("dup", "0.5"), rb("dup", "0.5")]).is_err()); + + // Count boundary: exactly 20 (each 0.05, summing to 1.0) is accepted; 21 rejects. + let twenty: Vec = + (0..20).map(|i| rb(&format!("b{i}"), "0.05")).collect(); + assert!(validate_reward_beneficiaries(&twenty).is_ok()); + let twenty_one: Vec = + (0..21).map(|i| rb(&format!("b{i}"), "0.05")).collect(); + assert!(validate_reward_beneficiaries(&twenty_one).is_err()); + + // Valid two-way split. + assert!(validate_reward_beneficiaries(&[rb("a", "0.8"), rb("b", "0.2")]).is_ok()); + } } diff --git a/crates/decman/tests/common/governance.rs b/crates/decman/tests/common/governance.rs index 095f5712..9994e7b5 100644 --- a/crates/decman/tests/common/governance.rs +++ b/crates/decman/tests/common/governance.rs @@ -45,21 +45,29 @@ pub fn propose_confirm_execute(label: &str, proposal: Value) -> Scenario p, - Err(e) => return Some(Err(e)), - }; - let path = format!("/governance/confirmations?party_id={party_id}"); - let s: GovernanceState = f.get_json(f.p1.http, &path).await.ok()?; - if s.domain_actions.len() != 1 { - return None; - } - let action = s.domain_actions.into_iter().next()?; - ctx.proposal_cid = Some(action.proposal_cid); - Some(Ok(())) - }) + { + let label_p1 = label.clone(); + move |f, ctx| { + let label_p1 = label_p1.clone(); + Box::pin(async move { + let party_id = match f.party_id() { + Ok(p) => p, + Err(e) => return Some(Err(e)), + }; + let path = format!("/governance/confirmations?party_id={party_id}"); + let s: GovernanceState = f.get_json(f.p1.http, &path).await.ok()?; + // Match THIS cycle's proposal by action label rather than + // assuming it is the only pending domain action. A prior + // phase (e.g. notification_feed) can leave an unrelated + // proposal pending, so `len() == 1` is not a safe gate. + let action = s + .domain_actions + .into_iter() + .find(|a| a.action_label == label_p1)?; + ctx.proposal_cid = Some(action.proposal_cid); + Some(Ok(())) + }) + } }, ) .then( @@ -115,7 +123,11 @@ pub fn propose_confirm_execute(label: &str, proposal: Value) -> Scenario Scenario p, @@ -186,7 +198,14 @@ pub fn propose_confirm_execute(label: &str, proposal: Value) -> Scenario Value { + let dso = &coupons + .first() + .expect("at least one coupon to seed") + .dso + .clone(); + let creates: Vec = coupons + .iter() + .map(|c| { + json!({ + "CreateCommand": { + "templateId": REWARD_COUPON_V2_TEMPLATE, + "createArguments": { + "dso": c.dso, + "provider": c.provider, + "round": { "number": c.round.to_string() }, + "amount": c.amount, + "expiresAt": c.expires_at, + "providerIsObserver": true, + "beneficiary": Value::Null, + } + } + }) + }) + .collect(); + json!({ + "commands": creates, + "commandId": command_id, + "userId": "ledger-api-user", + "actAs": [dso], + "readAs": [dso], + }) +} + +/// Build the `/v2/state/active-contracts` body: active `RewardCouponV2` +/// contracts visible to `party`, as of `offset`. +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, + }) +} + +/// Extract `(beneficiary, amount)` from each active `RewardCouponV2` in an ACS +/// response. `beneficiary` is `None` for an unassigned coupon. +pub fn parse_coupon_amounts(acs_response: &Value) -> Vec<(Option, String)> { + acs_response + .as_array() + .into_iter() + .flatten() + .filter_map(|entry| { + let payload = + entry.pointer("/contractEntry/JsActiveContract/createdEvent/createArgument")?; + let amount = payload.get("amount")?.as_str()?.to_string(); + let beneficiary = payload + .get("beneficiary") + .and_then(|b| b.as_str()) + .map(|s| s.to_string()); + Some((beneficiary, amount)) + }) + .collect() +} + +/// True when `beneficiary_total` is ~4x `operator_total` (the 0.8 / 0.2 split), +/// within `tolerance` (absolute, on the ratio). +pub fn split_ok(beneficiary_total: f64, operator_total: f64, tolerance: f64) -> bool { + if operator_total <= 0.0 { + return false; + } + (beneficiary_total / operator_total - 4.0).abs() <= tolerance +} + +/// Normalize the `/v2/state/active-contracts` response body into a JSON array. +/// The endpoint may return a JSON array or newline-delimited JSON objects +/// depending on the Canton version; both become a `Value::Array` here so +/// `parse_coupon_amounts` has one shape to read. +fn normalize_acs_body(text: &str) -> anyhow::Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Ok(Value::Array(vec![])); + } + if trimmed.starts_with('[') { + return serde_json::from_str(trimmed).context("parse ACS array body"); + } + let items = trimmed + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str::(l).context("parse ACS ndjson line")) + .collect::>>()?; + Ok(Value::Array(items)) +} + +impl Fixture { + /// POST a create/exercise command to a participant's JSON Ledger API, + /// returning the raw JSON response. Uses the same bearer as the other + /// harness calls (localnet MOCK_TOKEN). + pub async fn submit_create(&self, port: u16, body: &Value) -> anyhow::Result { + self.post_json(port, "/v2/commands/submit-and-wait", body) + .await + .context("POST /v2/commands/submit-and-wait") + } + + /// GET the participant's current ledger-end offset. + pub async fn ledger_end(&self, port: u16) -> anyhow::Result { + let r: Value = self + .get_json(port, "/v2/state/ledger-end") + .await + .context("GET /v2/state/ledger-end")?; + r.get("offset") + .and_then(|o| o.as_i64()) + .context("ledger-end response missing integer offset") + } + + /// Read the decoded active `RewardCouponV2` coupons visible to `party` as of + /// the current ledger end, returning `(beneficiary, amount)` pairs. Fetches + /// the raw body and normalizes array-or-NDJSON before parsing, so a + /// Canton-version difference in the response framing does not break it. + pub async fn active_reward_coupons( + &self, + port: u16, + party: &str, + ) -> anyhow::Result, String)>> { + let offset = self.ledger_end(port).await?; + let body = active_contracts_request(party, REWARD_COUPON_V2_TEMPLATE, offset); + let (status, text) = self + .post_expect_status(port, "/v2/state/active-contracts", &body) + .await + .context("POST /v2/state/active-contracts")?; + if !status.is_success() { + anyhow::bail!("POST /v2/state/active-contracts returned {status}: {text}"); + } + Ok(parse_coupon_amounts(&normalize_acs_body(&text)?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_command_shapes_unassigned_coupon() { + let c = SeedCoupon { + dso: "dso::1220".into(), + provider: "decparty::1220".into(), + amount: "100.0".into(), + expires_at: "2026-07-24T20:00:00Z".into(), + round: 0, + }; + let v = reward_coupon_create_command(std::slice::from_ref(&c), "seed-1"); + assert_eq!(v["commands"].as_array().expect("commands array").len(), 1); + let cmd = &v["commands"][0]["CreateCommand"]; + assert_eq!(cmd["templateId"], REWARD_COUPON_V2_TEMPLATE); + let args = &cmd["createArguments"]; + assert_eq!(args["dso"], "dso::1220"); + assert_eq!(args["provider"], "decparty::1220"); + assert_eq!(args["amount"], "100.0"); + assert_eq!(args["expiresAt"], "2026-07-24T20:00:00Z"); + assert_eq!(args["round"]["number"], "0"); // Int encoded as string + assert_eq!(args["providerIsObserver"], true); + assert!(args["beneficiary"].is_null()); // unassigned + assert_eq!(v["actAs"][0], "dso::1220"); // signatory = dso + assert_eq!(v["userId"], "ledger-api-user"); + assert_eq!(v["commandId"], "seed-1"); + } + + #[test] + fn create_command_batches_many_coupons_into_one_transaction() { + // Seeding a multi-chunk set must not cost one round trip per coupon. + let seeds: Vec = (0..20) + .map(|i| SeedCoupon { + dso: "dso::1220".into(), + provider: "decparty::1220".into(), + amount: "100.0".into(), + expires_at: "2026-07-24T20:00:00Z".into(), + round: i, + }) + .collect(); + let v = reward_coupon_create_command(&seeds, "seed-batch"); + assert_eq!(v["commands"].as_array().expect("commands array").len(), 20); + assert_eq!( + v["commands"][19]["CreateCommand"]["createArguments"]["round"]["number"], + "19" + ); + // One actAs for the whole transaction. + assert_eq!(v["actAs"].as_array().expect("actAs array").len(), 1); + } + + #[test] + fn active_contracts_request_filters_by_party_and_template() { + // Distinct dummy template id (not REWARD_COUPON_V2_TEMPLATE) so this + // proves the value is threaded through, not hardcoded. + let v = active_contracts_request("benef::1220", "#dummy:Mod:Ent", 42); + assert_eq!(v["activeAtOffset"], 42); + let f = &v["eventFormat"]["filtersByParty"]["benef::1220"]["cumulative"][0]["identifierFilter"] + ["TemplateFilter"]["value"]; + assert_eq!(f["templateId"], "#dummy:Mod:Ent"); + } + + #[test] + fn parse_coupon_amounts_reads_beneficiary_and_amount() { + // Mirrors the /v2/state/active-contracts POST response: an array of + // entries, each { contractEntry: { JsActiveContract: { createdEvent: + // { createArgument: } } } }. + let resp = serde_json::json!([ + {"contractEntry": {"JsActiveContract": {"createdEvent": { + "createArgument": {"beneficiary": "benef::1220", "amount": "80.0"} + }}}}, + {"contractEntry": {"JsActiveContract": {"createdEvent": { + "createArgument": {"beneficiary": "op::1220", "amount": "20.0"} + }}}} + ]); + let mut got = parse_coupon_amounts(&resp); + got.sort(); + let mut want = vec![ + (Some("benef::1220".to_string()), "80.0".to_string()), + (Some("op::1220".to_string()), "20.0".to_string()), + ]; + want.sort(); + assert_eq!(got, want); + } + + #[test] + fn split_ok_accepts_four_to_one() { + assert!(split_ok(80.0, 20.0, 0.01)); + assert!(!split_ok(50.0, 50.0, 0.01)); + } + + #[test] + fn normalize_acs_body_handles_array_and_ndjson() { + let entry = r#"{"contractEntry":{"JsActiveContract":{"createdEvent":{"createArgument":{"beneficiary":"b::1","amount":"80.0"}}}}}"#; + // JSON array form + let arr = format!("[{entry}]"); + let v = normalize_acs_body(&arr).unwrap(); + assert_eq!(v.as_array().unwrap().len(), 1); + // newline-delimited form (two objects, one blank line) + let ndjson = format!("{entry}\n\n{entry}\n"); + let v = normalize_acs_body(&ndjson).unwrap(); + assert_eq!(v.as_array().unwrap().len(), 2); + // both parse into the same shape parse_coupon_amounts expects + assert_eq!(parse_coupon_amounts(&v).len(), 2); + // empty / whitespace-only body normalizes to an empty array + assert!( + normalize_acs_body("") + .unwrap() + .as_array() + .unwrap() + .is_empty() + ); + assert!( + normalize_acs_body(" \n ") + .unwrap() + .as_array() + .unwrap() + .is_empty() + ); + } +} diff --git a/crates/decman/tests/common/mod.rs b/crates/decman/tests/common/mod.rs index 1cf6a9e5..604be4e6 100644 --- a/crates/decman/tests/common/mod.rs +++ b/crates/decman/tests/common/mod.rs @@ -6,6 +6,7 @@ pub mod db; pub mod governance; pub mod http; pub mod invitations; +pub mod ledger_api; pub mod operator; pub mod phases; pub mod processes; @@ -94,6 +95,8 @@ pub struct Fixture { pub p1_member_party: Option, pub p2_member_party: Option, pub p3_member_party: Option, + pub reward_beneficiary_party: Option, + pub reward_operator_party: Option, pub provider_service_cid: Option, pub allocation_factory_cid: Option, pub instrument_configuration_cid: Option, @@ -237,6 +240,8 @@ impl Fixture { p1_member_party: None, p2_member_party: None, p3_member_party: None, + reward_beneficiary_party: None, + reward_operator_party: None, provider_service_cid: None, allocation_factory_cid: None, instrument_configuration_cid: None, @@ -301,6 +306,16 @@ impl Fixture { .as_deref() .context("p3_member_party not set") } + pub fn reward_beneficiary_party(&self) -> anyhow::Result<&str> { + self.reward_beneficiary_party + .as_deref() + .context("reward_beneficiary_party not set — seed_reward_coupons must run first") + } + pub fn reward_operator_party(&self) -> anyhow::Result<&str> { + self.reward_operator_party + .as_deref() + .context("reward_operator_party not set — seed_reward_coupons must run first") + } /// Build a `Fixture` with hardcoded test values, bypassing env vars entirely. /// Used by unit tests for the Scenario DSL — those tests don't make HTTP calls, @@ -349,6 +364,8 @@ impl Fixture { p1_member_party: None, p2_member_party: None, p3_member_party: None, + reward_beneficiary_party: None, + reward_operator_party: None, provider_service_cid: None, allocation_factory_cid: None, instrument_configuration_cid: None, diff --git a/crates/decman/tests/common/phases/coupon_reassignment.rs b/crates/decman/tests/common/phases/coupon_reassignment.rs new file mode 100644 index 00000000..5dddddc8 --- /dev/null +++ b/crates/decman/tests/common/phases/coupon_reassignment.rs @@ -0,0 +1,439 @@ +//! CIP-104 Mode A coupon-reassignment e2e. Runs on **localnet** +//! every CI run (the harness seeds its own coupons — see `seed_reward_coupons`) +//! and, opt-in, against **devnet**. +//! +//! Exercises the **delegation model** end-to-end. One threshold governance +//! vote (`SetupCouponReassignmentDelegation`) records the split and the +//! authorized `assigners` once, into an on-ledger `CouponReassignmentDelegation`. +//! From then on there is **no per-round voting**: each node's background +//! reward-automation loop (`run_reward_automation_loop`, spawned in +//! `start_server`) reads the active delegation and — if this node's member +//! party is a listed assigner — exercises `Delegation_Assign` directly to +//! reassign the decparty's unassigned `RewardCouponV2` coupons to the +//! baked-in beneficiaries. Only ONE member instance needs to run to reassign. +//! +//! ## Why this cannot run in normal CI (and how it stays harmless) +//! +//! Gated two ways: +//! 1. **Opt-in env var** `DECPM_IT_REWARD` in `governance_workflows.rs` — the +//! phase is not called at all unless that is set. +//! 2. **Runtime precondition skip** (below) — **devnet-only**: if the +//! decparty has no `RewardCouponV2` coupons, the phase logs a SKIP line +//! and returns `Ok(())`. On **localnet** the phase seeds its own coupons +//! (`seed_reward_coupons`) and hard-fails instead of skipping if none are +//! visible after a short poll — there is no silent no-op path there. +//! +//! To actually observe reassignment on devnet, operational preconditions +//! (spec §13) must hold — none are reproducible from this harness: +//! - The decparty (`f.party_id()`) must be an app-provider whose coupons +//! carry `provider == decparty` and are **unassigned** (`beneficiary = +//! null`). On devnet that is `cbtc-network`; a fresh harness-allocated +//! decparty earns no coupons, so the live run must target a decparty that +//! does. +//! - **Mode-B collection must be paused** for that decparty (coordinate with +//! the team) so coupons re-accumulate unassigned instead of being swept to +//! 0. Coupons reappear within ~one round. +//! - The test nodes must run with a **short reward-automation interval** so +//! the loop reassigns within the poll deadline. The default is 300s; set +//! `DECPM_REWARD_AUTOMATION_INTERVAL_SECS` (or `--reward-automation-interval-secs`, +//! e.g. 15-30s) on the test nodes. +//! - At least one of the delegation's `assigners` must be a member party this +//! node holds credentials for (else the loop skips the decparty). Here the +//! assigners are `[p1_member, p2_member]`; on the live `cbtc-network` run +//! they are the active attestors (`attestor-1`, `attestor-2`). +//! +//! ## Field-level split assertions need PQS, not this harness +//! +//! The DecMan `/contracts/query` HTTP endpoint returns only `{contract_id}` per +//! contract (`ContractsQueryResponse`) — it does **not** expose decoded fields. +//! So this phase observes, at the HTTP layer: delegation **presence** (the +//! keyless-singleton invariant: exactly one `CouponReassignmentDelegation`) and +//! coupon **archival** (an originally-visible unassigned coupon cid is gone +//! after a tick). It **cannot** assert, at the HTTP layer, that each resulting +//! coupon carries a specific `beneficiary` or the 0.8 / 0.2 `amount` shares. +//! +//! On **localnet**, the split IS asserted by value: each beneficiary party is +//! an observer of its own assigned `RewardCouponV2`, so the phase reads the +//! decoded amount via the JSON Ledger API (`active_reward_coupons`) and checks +//! the 80.0 / 20.0 shares directly — no PQS needed. On **devnet**, the +//! per-beneficiary field checks still require decoded reads not exposed by +//! `/contracts/query` and must be verified against devnet PQS `pqs_cbtc` on the +//! real run (issue #271) — see the TODO on the final assertion. Beneficiary +//! self-minting (spec §4.3) is a separate precondition (the beneficiaries' own +//! agents) and is likewise verified out-of-band. +//! +//! ## Security property +//! +//! The split is baked into the delegation and `Delegation_Assign` reads it, so +//! a caller cannot alter it, and only a listed `assigner` may exercise the +//! choice. The per-round path involves no proposal at all, so there is no +//! "craft a mismatched proposal" case. The authoritative coverage is the DAML unit test +//! `test_non_assigner_cannot_reassign` plus the baked-split assertion. +//! The devnet ledger-level negative (submit `Delegation_Assign` as a party not +//! in `assigners`, expect ledger rejection) is **not expressible through this +//! HTTP harness**: DecMan exposes no endpoint to submit an arbitrary ledger +//! command as a chosen party — the automation only ever submits as an +//! authorized assigner — so `run_negative_case` documents it as a manual +//! pre-merge ops step rather than inventing a harness capability. See the +//! module-level report note. + +use std::{collections::HashSet, time::Duration}; + +use serde_json::json; +use tracing::{info, warn}; + +use crate::common::{ + Fixture, + governance::propose_confirm_execute, + ledger_api::P1_JSON_API, + phases::seed_reward_coupons::{SEED_AMOUNT, SEED_COUPON_COUNT}, + scenario::Scenario, + types::ContractsQueryResponse, +}; + +/// `#splice-api-reward-assignment-v1`, URL-encoded — the `RewardCoupon` +/// interface package (concrete implementer on devnet: `RewardCouponV2`). +const REWARD_ASSIGN_PKG: &str = "%23splice-api-reward-assignment-v1"; +/// `#governance-rewards-v1`, URL-encoded — holds the +/// `CouponReassignmentDelegation` template. +const GOVERNANCE_REWARDS_PKG: &str = "%23governance-rewards-v1"; + +/// Generous ceiling for the reassignment step. Under a short +/// `DECPM_REWARD_AUTOMATION_INTERVAL_SECS` (see the module doc) the loop +/// reassigns within seconds; this only bites when the nodes are misconfigured +/// (still on the 300s default) or paused Mode-B collection was not arranged. +const REASSIGN_TIMEOUT: Duration = Duration::from_secs(600); + +/// Devnet query: the `RewardCoupon` *interface* (matches any implementer; on +/// cbtc-network that is `RewardCouponV2`). Real auth supports InterfaceFilter. +fn reward_coupon_query_path(party_id: &str) -> String { + format!( + "/contracts/query?party_id={party_id}&package_id={REWARD_ASSIGN_PKG}\ + &module_name=Splice.Api.RewardAssignmentV1&entity_name=RewardCoupon&interface=true" + ) +} + +/// Localnet query: the *concrete* `Splice.Amulet:RewardCouponV2` template. +/// Localnet builds DecMan with `--features test-mode`, where `/contracts/query` +/// falls back to a `WildcardFilter` ACS read and matches results by concrete +/// `module_name`/`entity_name` (mock auth can't use real Template/Interface +/// filters). An interface query (`RewardCoupon`) therefore never matches and +/// returns empty — we must query the concrete implementing template. +fn reward_coupon_v2_query_path(party_id: &str) -> String { + format!( + "/contracts/query?party_id={party_id}&package_id=%23splice-amulet\ + &module_name=Splice.Amulet&entity_name=RewardCouponV2&interface=false" + ) +} + +fn delegation_query_path(party_id: &str) -> String { + format!( + "/contracts/query?party_id={party_id}&package_id={GOVERNANCE_REWARDS_PKG}\ + &module_name=Governance.Rewards.CouponReassignmentDelegation\ + &entity_name=CouponReassignmentDelegation" + ) +} + +/// Contract ids of the decparty's `RewardCoupon` contracts (cid-only; the HTTP +/// endpoint does not surface decoded fields, so we cannot filter by +/// `beneficiary == null` here — see the module doc). +async fn query_reward_coupons(f: &Fixture, party_id: &str) -> anyhow::Result> { + let path = match f.target { + crate::common::TestTarget::Localnet => reward_coupon_v2_query_path(party_id), + crate::common::TestTarget::Devnet => reward_coupon_query_path(party_id), + }; + let r: ContractsQueryResponse = f.get_json(f.p1.http, &path).await?; + Ok(r.contracts.into_iter().map(|c| c.contract_id).collect()) +} + +pub async fn run(f: &mut Fixture) -> anyhow::Result<()> { + info!("Phase: coupon_reassignment (CIP-104 Mode A delegation model)"); + + let decparty = f.party_id()?.to_string(); + + // ------------------------------------------------------------------ + // Precondition check (the runtime half of the gate). Target-aware: + // - Localnet: seed_reward_coupons committed coupons; empty here is a + // hard failure after brief poll (no silent no-op). + // - Devnet: empty coupons log SKIP and return Ok(()). + // (Ordered before delegation setup so early exit avoids governance work.) + // ------------------------------------------------------------------ + let initial_coupon_cids = match f.target { + crate::common::TestTarget::Localnet => { + // seed_reward_coupons committed the coupons synchronously; poll only + // to absorb any ledger->DecMan read lag, then hard-fail. A silent + // skip here would turn the whole phase into a false-positive no-op. + // + // Wait for the FULL seeded set, not merely a non-empty one: the seed + // commits in several transactions, so an early read can return a + // subset, and the "every seeded coupon archived" assertion below + // would then be evaluated over that subset instead of all of them. + let deadline = std::time::Instant::now() + Duration::from_secs(60); + let cids = loop { + let cids = query_reward_coupons(f, &decparty).await?; + if cids.len() >= SEED_COUPON_COUNT || std::time::Instant::now() >= deadline { + break cids; + } + tokio::time::sleep(Duration::from_secs(2)).await; + }; + anyhow::ensure!( + cids.len() >= SEED_COUPON_COUNT, + "coupon_reassignment (localnet): saw {} unassigned RewardCouponV2 for {decparty}, \ + expected the {SEED_COUPON_COUNT} seeded — seed_reward_coupons must run first \ + and commit them all", + cids.len() + ); + cids + } + crate::common::TestTarget::Devnet => { + let cids = query_reward_coupons(f, &decparty).await?; + if cids.is_empty() { + warn!( + "coupon_reassignment IT SKIPPED: no unassigned RewardCouponV2 for {decparty} — \ + needs live coupons with Mode-B collection paused (precondition)" + ); + return Ok(()); + } + cids + } + }; + info!( + "coupon_reassignment: {} candidate coupon(s) visible for {decparty}", + initial_coupon_cids.len() + ); + + // ------------------------------------------------------------------ + // Given: create the delegation with ONE threshold governance vote + // (propose -> confirm -> execute), recording the assigners and the + // baked-in 0.8 / 0.2 split. `prior_delegation = null` (first delegation). + // + // Party roles: + // On localnet, assigners = [p1_member, p2_member], and beneficiaries are + // the two dedicated non-assigner parties the seed phase allocated + // ([reward_beneficiary_party @ 0.8, reward_operator_party @ 0.2]) — kept + // disjoint from the assigners so the split-by-value assertion below + // observes each beneficiary's own coupons unambiguously. + // + // On devnet (harness stand-ins; the live cbtc-network run uses the real + // devnet parties per the operational preconditions): + // assigners = [p1_member, p2_member] -> attestor-1, attestor-2 + // beneficiaries = [p2_member @ 0.8, p3_member @ 0.2] -> cbtc-beneficiary, + // operator + // p3_member is deliberately NOT an assigner — it is the non-assigner used + // by the security note below. (A beneficiary is not thereby an assigner.) + // ------------------------------------------------------------------ + let dso_party = f.p1_member_party()?.to_string(); // localnet DSO stand-in + let assigner_a = f.p1_member_party()?.to_string(); + let assigner_b = f.p2_member_party()?.to_string(); + // Beneficiaries must be disjoint from assigners. On localnet the seed phase + // allocated two dedicated non-assigner parties; on devnet keep the existing + // stand-ins (issue #271 wires the real cbtc-beneficiary/operator). + let (benef_a, benef_b) = match f.target { + crate::common::TestTarget::Localnet => ( + f.reward_beneficiary_party()?.to_string(), + f.reward_operator_party()?.to_string(), + ), + crate::common::TestTarget::Devnet => ( + f.p2_member_party()?.to_string(), + f.p3_member_party()?.to_string(), + ), + }; + propose_confirm_execute( + "SetupCouponReassignmentDelegation", + json!({ + "type": "setup_coupon_reassignment_delegation", + // On localnet the harness substitutes p1_member for the DSO, which is + // the party seed_reward_coupons mints the coupons as. + "dso": dso_party, + "assigners": [assigner_a, assigner_b], + "new_beneficiaries": [ + {"beneficiary": benef_a, "percentage": "0.8"}, + {"beneficiary": benef_b, "percentage": "0.2"}, + ], + "prior_delegation": null, + }), + ) + .run(f) + .await?; + + Scenario::new("CouponReassignmentDelegation present") + .then( + "exactly one CouponReassignmentDelegation", + Duration::from_secs(60), + |f, _| { + Box::pin(async move { + let party_id = match f.party_id() { + Ok(p) => p, + Err(e) => return Some(Err(e)), + }; + let r: ContractsQueryResponse = f + .get_json(f.p1.http, &delegation_query_path(party_id)) + .await + .ok()?; + (r.contracts.len() == 1).then_some(Ok(())) + }) + }, + ) + .run(f) + .await?; + + // ------------------------------------------------------------------ + // When + Then (happy path): with the delegation in place and Mode-B + // collection paused, each node's background loop reads the delegation and + // (for a node whose member is a listed assigner) exercises + // Delegation_Assign on its own — no vote per round. Proof-at-the-HTTP-layer: + // at least one originally-visible unassigned coupon cid is now archived. + // ------------------------------------------------------------------ + // On localnet the seeded set spans more than one chunk (see + // `seed_reward_coupons::SEED_COUPON_COUNT`), so requiring *every* seeded + // coupon to be archived exercises the drain loop across chunk boundaries — + // a single-chunk assertion would pass while later chunks were dropped. On + // devnet the coupon set is whatever the ledger happens to hold and other + // automation may touch it, so one archived coupon remains the bar there. + let require_all_archived = f.target == crate::common::TestTarget::Localnet; + let archived_criterion = if require_all_archived { + "every seeded coupon archived by Delegation_Assign (spans >1 chunk)" + } else { + "at least one candidate coupon archived by Delegation_Assign" + }; + Scenario::new("delegation-model reassignment") + .then(archived_criterion, REASSIGN_TIMEOUT, { + let initial = initial_coupon_cids.clone(); + move |f, _| { + let initial = initial.clone(); + Box::pin(async move { + let party_id = match f.party_id() { + Ok(p) => p, + Err(e) => return Some(Err(e)), + }; + let current = query_reward_coupons(f, party_id).await.ok()?; + // Delegation_Assign archives each targeted unassigned + // coupon and creates one per beneficiary. + // + // TODO(devnet/PQS): the per-beneficiary field-level + // checks (one RewardCouponV2 per + // beneficiary with `beneficiary ∈ {benef_a, benef_b}` and + // the 0.8 / 0.2 `amount` shares) require decoded reads not + // exposed by /contracts/query — verify them against devnet + // PQS `pqs_cbtc` on the real run. + let mut gone = initial.iter().filter(|c| !current.contains(*c)); + if require_all_archived { + gone.count().eq(&initial.len()).then_some(Ok(())) + } else { + gone.next().map(|_| Ok(())) + } + }) + } + }) + .run(f) + .await?; + + // Localnet: assert the 0.8 / 0.2 split BY VALUE (the automated replacement + // for the devnet/PQS TODO). Each beneficiary party is an observer of its own + // assigned RewardCouponV2, so we read the decoded amount via the JSON Ledger + // API. On devnet this stays a PQS check on the real run (issue #271). + if f.target == crate::common::TestTarget::Localnet { + let benef = f.reward_beneficiary_party()?.to_string(); + let operator = f.reward_operator_party()?.to_string(); + // Totals over the whole seeded set, so the split is checked across chunk + // boundaries rather than on one coupon. + let seeded_total = SEED_COUPON_COUNT as f64 * SEED_AMOUNT; + let expect_benef = seeded_total * 0.8; + let expect_operator = seeded_total * 0.2; + Scenario::new("0.8/0.2 split by value") + .then( + "beneficiary total ~4x operator total across all seeded coupons", + Duration::from_secs(120), + move |f, _| { + let benef = benef.clone(); + let operator = operator.clone(); + Box::pin(async move { + // Log a hard read error (e.g. an ACS shape mismatch) on + // each attempt instead of silently swallowing it into a + // retry that ends in a generic timeout — makes the first + // live/CI run diagnosable. Still returns None to retry + // (the 120s deadline bounds it). + let b = match f.active_reward_coupons(P1_JSON_API, &benef).await { + Ok(v) => v, + Err(e) => { + warn!("split assertion: reading beneficiary coupons failed: {e:#}"); + return None; + } + }; + let o = match f.active_reward_coupons(P1_JSON_API, &operator).await { + Ok(v) => v, + Err(e) => { + warn!("split assertion: reading operator coupons failed: {e:#}"); + return None; + } + }; + let sum = |v: &[(Option, String)], who: &str| -> f64 { + v.iter() + .filter(|(bene, _)| bene.as_deref() == Some(who)) + .filter_map(|(_, amt)| amt.parse::().ok()) + .sum() + }; + let b_total = sum(&b, &benef); + let o_total = sum(&o, &operator); + // Wait until both beneficiary coupons exist, then assert. + if b_total <= 0.0 || o_total <= 0.0 { + return None; + } + // Wait for the full seeded total before judging: a + // partial read mid-drain would look like a bad split. + if b_total + o_total < seeded_total - 0.05 { + return None; + } + let ok = crate::common::ledger_api::split_ok(b_total, o_total, 0.01) + && (b_total - expect_benef).abs() < 0.05 + && (o_total - expect_operator).abs() < 0.05; + Some(if ok { + Ok(()) + } else { + Err(anyhow::anyhow!( + "split mismatch: beneficiary={b_total} operator={o_total} \ + (expected {expect_benef} / {expect_operator})" + )) + }) + }) + }, + ) + .run(f) + .await?; + } + + // ------------------------------------------------------------------ + // Negative (security property). See the module doc: the + // authoritative coverage is DAML (`test_non_assigner_cannot_reassign` + + // the baked-split assertion). The devnet ledger-level negative is a manual + // ops step because this HTTP harness cannot submit Delegation_Assign as a + // non-assigner party. + // ------------------------------------------------------------------ + run_negative_case(f, &decparty).await +} + +/// Documents the security-property negative rather than +/// exercising it here. +/// +/// The property — only a listed `assigner` may exercise `Delegation_Assign`, +/// and the split is baked in so a caller cannot alter it — is enforced in DAML +/// and covered by the `test_non_assigner_cannot_reassign` unit test. +/// The devnet ledger-level assertion (submit `Delegation_Assign` as +/// `p3_member`, a party **not** in `assigners`, and expect the ledger to reject +/// it) is **not expressible through this harness**: DecMan exposes no endpoint +/// to submit an arbitrary ledger command as a chosen party (the reward +/// automation only ever submits as an authorized assigner). Inventing such an +/// endpoint is out of scope here (no new runtime code), so the devnet +/// negative is deferred to the pre-merge ops run (submit via the ledger API / +/// a daml script as the non-assigner and confirm rejection). +async fn run_negative_case(f: &Fixture, decparty: &str) -> anyhow::Result<()> { + let non_assigner = f.p3_member_party()?; + info!( + "coupon_reassignment security property: enforced in DAML \ + (test_non_assigner_cannot_reassign). Devnet ledger-level negative — \ + submit Delegation_Assign for {decparty} as non-assigner {non_assigner}, expect \ + rejection — is a manual pre-merge ops step (no HTTP path to submit as an \ + arbitrary party)." + ); + Ok(()) +} diff --git a/crates/decman/tests/common/phases/deploy_gov_core.rs b/crates/decman/tests/common/phases/deploy_gov_core.rs index 9bb3717a..219edc62 100644 --- a/crates/decman/tests/common/phases/deploy_gov_core.rs +++ b/crates/decman/tests/common/phases/deploy_gov_core.rs @@ -8,15 +8,17 @@ use crate::common::{ Fixture, MemberCreds, ParticipantAdminCreds, TestTarget, http::{probe_workflow_run_visible, probe_workflow_status}, invitations::{InvitationIds, post_accept_invitation, probe_pending_invitation}, + ledger_api::{P1_JSON_API, P2_JSON_API, P3_JSON_API}, scenario::Scenario, types::{AllocatePartyResponse, DecentralizedPartiesResponse, GovernanceStateLookup}, }; -const P1_JSON_API: u16 = 3975; -const P2_JSON_API: u16 = 2975; -const P3_JSON_API: u16 = 4975; - -async fn allocate_party(f: &Fixture, port: u16, hint: &str, name: &str) -> anyhow::Result { +pub(crate) async fn allocate_party( + f: &Fixture, + port: u16, + hint: &str, + name: &str, +) -> anyhow::Result { info!("Allocating '{hint}' on {name} (port {port})"); let req = json!({ "party_id_hint": hint, "local_metadata": { "annotations": {} } }); let r: AllocatePartyResponse = f @@ -52,7 +54,12 @@ async fn grant_rights_devnet( Ok(()) } -async fn grant_rights(f: &Fixture, port: u16, party: &str, name: &str) -> anyhow::Result<()> { +pub(crate) async fn grant_rights( + f: &Fixture, + port: u16, + party: &str, + name: &str, +) -> anyhow::Result<()> { let req = json!({ "userId": "ledger-api-user", "rights": [ diff --git a/crates/decman/tests/common/phases/distribute_dars.rs b/crates/decman/tests/common/phases/distribute_dars.rs index 7970ea24..3c0f67dd 100644 --- a/crates/decman/tests/common/phases/distribute_dars.rs +++ b/crates/decman/tests/common/phases/distribute_dars.rs @@ -17,6 +17,7 @@ const DAR_FILES: &[&str] = &[ "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-v1-0.1.4.dar", ]; /// Map of expected package_id → (module_name, entity_name) of a well-known @@ -45,6 +46,11 @@ const DAR_PROBES: &[(&str, &str, &str)] = &[ "Governance.UtilityOnboarding.SetupUtility", "SetupUtility", ), + ( + "%23governance-rewards-v1", + "Governance.Rewards.CouponReassignmentDelegation", + "CouponReassignmentDelegation", + ), ]; async fn dar_present_on(f: &Fixture, port: u16, pkg: &str, module: &str, entity: &str) -> bool { diff --git a/crates/decman/tests/common/phases/mod.rs b/crates/decman/tests/common/phases/mod.rs index 296feb29..1ef401fd 100644 --- a/crates/decman/tests/common/phases/mod.rs +++ b/crates/decman/tests/common/phases/mod.rs @@ -10,6 +10,7 @@ pub mod concurrent_cross_workflows; pub mod concurrent_sibling_cancel; pub mod concurrent_sibling_decline; pub mod contracts_quorum_completes; +pub mod coupon_reassignment; pub mod create_dec_party; pub mod deploy_gov_core; pub mod dismiss_failed_cleans_artifacts; @@ -29,6 +30,7 @@ pub mod restart_peer_resume; pub mod restart_with_concurrent_kinds; pub mod retry_coordinator_broadcast; pub mod retry_with_offline_peer; +pub mod seed_reward_coupons; pub mod start_handler_conflict_409; pub mod token_custody; pub mod two_member_party; diff --git a/crates/decman/tests/common/phases/seed_reward_coupons.rs b/crates/decman/tests/common/phases/seed_reward_coupons.rs new file mode 100644 index 00000000..e44a3492 --- /dev/null +++ b/crates/decman/tests/common/phases/seed_reward_coupons.rs @@ -0,0 +1,93 @@ +//! Localnet-only: seed unassigned `RewardCouponV2` coupons so the CIP-104 Mode A +//! coupon-reassignment phase can exercise the assign + 0.8/0.2 split +//! deterministically in CI — no devnet, no reward-round issuance. +//! +//! On localnet the harness controls the DSO (it substitutes `p1_member`; see +//! `token_custody`). `RewardCouponV2`'s sole signatory is `dso`, so we create +//! the coupon directly via the participant's JSON Ledger API as `ledger-api-user` +//! acting as `p1_member`. `provider = decparty` + `providerIsObserver = true` +//! makes it visible to the decparty (the reassignment authority), and +//! `beneficiary = null` marks it unassigned (what the automation looks for). +//! `expiresAt = now + 36h` mirrors a freshly issued coupon at the real TTL, so +//! the phase exercises the automation on the same shape production sees. +//! Beneficiaries are two fresh non-assigner parties, matching the real +//! cbtc-network topology. + +use anyhow::Context; +use chrono::Utc; +use tracing::info; + +use crate::common::{ + Fixture, TestTarget, + ledger_api::{P1_JSON_API, SeedCoupon, reward_coupon_create_command}, + phases::deploy_gov_core::{allocate_party, grant_rights}, +}; + +/// Amulet amount per seeded coupon; the 0.8/0.2 split yields 80.0 / 20.0 each. +pub const SEED_AMOUNT: f64 = 100.0; + +/// How many unassigned coupons to seed. +/// +/// Deliberately **more than one chunk**: `run_reassign_once` sizes a chunk in +/// output creates (`MAX_CREATES / beneficiary_count` = 100/2 = 50 coupons here), +/// so 60 forces the drain loop to submit a second `Delegation_Assign` — the +/// multi-chunk path a single-coupon seed never reaches. (That the whole set +/// drains within *one* tick is pinned down by the `drain_assignable` unit tests; +/// an e2e poll cannot distinguish one draining tick from several chunking ones.) +pub const SEED_COUPON_COUNT: usize = 60; + +/// Coupons per seed transaction. Keeps each seeding submission near the size +/// devnet has proven (72 creates) so the seed itself is never the thing that +/// fails when the point of the test is what the automation does afterwards. +const SEED_TX_SIZE: usize = 20; + +pub async fn run(f: &mut Fixture) -> anyhow::Result<()> { + if f.target != TestTarget::Localnet { + info!("seed_reward_coupons: skipped (localnet-only)"); + return Ok(()); + } + info!("Phase: seed_reward_coupons (localnet)"); + + let decparty = f.party_id()?.to_string(); + let dso = f.p1_member_party()?.to_string(); // localnet DSO stand-in + + // Two fresh non-assigner beneficiary parties on participant-1, with + // ledger-api-user granted read so the split assertion can read their coupons. + let beneficiary_party = + allocate_party(&*f, P1_JSON_API, "cbtc-beneficiary", "participant-1").await?; + let operator_party = + allocate_party(&*f, P1_JSON_API, "reward-operator", "participant-1").await?; + grant_rights(&*f, P1_JSON_API, &beneficiary_party, "participant-1").await?; + grant_rights(&*f, P1_JSON_API, &operator_party, "participant-1").await?; + + // Freshly issued coupons at the real 36h TTL: well clear of the expiry + // margin, so select_assignable takes them on the next tick. Distinct rounds + // keep the seeded contracts distinguishable in a failure dump. + let expires_at = (Utc::now() + chrono::Duration::hours(36)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + let seeds: Vec = (0..SEED_COUPON_COUNT) + .map(|i| SeedCoupon { + dso: dso.clone(), + provider: decparty.clone(), + amount: format!("{SEED_AMOUNT:.1}"), + expires_at: expires_at.clone(), + round: i as i64, + }) + .collect(); + for (batch, chunk) in seeds.chunks(SEED_TX_SIZE).enumerate() { + let cmd = + reward_coupon_create_command(chunk, &format!("seed-coupons-{}-{batch}", f.run_id)); + f.submit_create(P1_JSON_API, &cmd) + .await + .with_context(|| format!("create seeded RewardCouponV2 batch {batch}"))?; + } + + info!( + "seed_reward_coupons: created {SEED_COUPON_COUNT} unassigned RewardCouponV2 for \ + {decparty} (beneficiary={beneficiary_party}, operator={operator_party})" + ); + f.reward_beneficiary_party = Some(beneficiary_party); + f.reward_operator_party = Some(operator_party); + Ok(()) +} diff --git a/crates/decman/tests/governance_workflows.rs b/crates/decman/tests/governance_workflows.rs index d4d6246f..4303bb8e 100644 --- a/crates/decman/tests/governance_workflows.rs +++ b/crates/decman/tests/governance_workflows.rs @@ -180,5 +180,25 @@ async fn governance_workflows_e2e() -> anyhow::Result<()> { // multi-instance workflows — full-mesh cross-acceptance of simultaneous // Onboarding + DARs coordinators on every node. phases::concurrent_cross_workflows::run(&mut f).await?; // G11 + + // CIP-104 Mode A coupon-reassignment e2e (delegation model). + // On localnet the harness seeds its own unassigned RewardCouponV2 coupons, + // so the assign + 0.8/0.2 split run and assert on EVERY CI run. On devnet + // there is no way to synthesize coupons, so it stays opt-in + // (DECPM_IT_REWARD) and depends on live preconditions (Mode-B paused) — + // see issue #271. See the phase module doc for the full operational + // preconditions. + match f.target { + common::TestTarget::Localnet => { + phases::seed_reward_coupons::run(&mut f).await?; + phases::coupon_reassignment::run(&mut f).await?; + } + common::TestTarget::Devnet => { + if std::env::var("DECPM_IT_REWARD").is_ok() { + phases::coupon_reassignment::run(&mut f).await?; + } + } + } + Ok(()) } diff --git a/daml/dars/splice-amulet-0.1.19.dar b/daml/dars/splice-amulet-0.1.19.dar new file mode 100644 index 00000000..d1f653c1 Binary files /dev/null and b/daml/dars/splice-amulet-0.1.19.dar differ diff --git a/daml/dars/splice-api-reward-assignment-v1-1.0.0.dar b/daml/dars/splice-api-reward-assignment-v1-1.0.0.dar new file mode 100644 index 00000000..76ac3705 Binary files /dev/null and b/daml/dars/splice-api-reward-assignment-v1-1.0.0.dar differ diff --git a/daml/governance-rewards-assign-test/daml.yaml b/daml/governance-rewards-assign-test/daml.yaml new file mode 100644 index 00000000..4fb59e73 --- /dev/null +++ b/daml/governance-rewards-assign-test/daml.yaml @@ -0,0 +1,22 @@ +sdk-version: 3.4.11 +name: governance-rewards-assign-test +version: 0.1.0 +source: daml +dependencies: + - daml-prim + - daml-stdlib + - daml-script +data-dependencies: + - ../governance-action-v1/.daml/dist/governance-action-v1-0.1.0.dar + - ../governance-core/.daml/dist/governance-core-v1-0.1.0.dar + - ../governance-rewards/.daml/dist/governance-rewards-v1-0.1.4.dar + - ../dars/splice-amulet-0.1.19.dar + - ../dars/splice-api-reward-assignment-v1-1.0.0.dar + - ../dars/splice-api-token-metadata-v1-1.0.0.dar + - ../dars/testlib-0.1.0.dar +build-options: + - --target=2.2 + - -Wno-upgrade-interfaces + - -Wno-template-interface-depends-on-daml-script + - --ghc-option=-Wunused-binds + - --ghc-option=-Wunused-matches diff --git a/daml/governance-rewards-assign-test/daml/Governance/Rewards/AssignTestUtils.daml b/daml/governance-rewards-assign-test/daml/Governance/Rewards/AssignTestUtils.daml new file mode 100644 index 00000000..5815c4a8 --- /dev/null +++ b/daml/governance-rewards-assign-test/daml/Governance/Rewards/AssignTestUtils.daml @@ -0,0 +1,101 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Test utilities for the coupon-reassignment-delegation tests. Copied (and +-- adapted with an added `dso` party) from `Governance.TestUtils` in +-- governance-core-test so this package does not need to depend on that +-- test-support package's wallet-bearing closure. +-- In real Canton, members have readAs rights on the governance party via +-- participant topology. In Daml Script, we simulate this with actAs/readAs. +module Governance.Rewards.AssignTestUtils where + +import DA.Set as Set hiding (filter) +import DA.Time (addRelTime, hours, minutes) +import Daml.Script + +import Splice.Amulet (RewardCouponV2(..)) +import Splice.Types (Round(..)) +import Splice.Api.RewardAssignmentV1 (RewardCoupon) + +import Governance.Action (GovernableAction) +import Governance.Confirmation +import Governance.ExecutionResult +import Governance.Rules + +-- | Standard test party allocation, plus the DSO party used to create +-- RewardCouponV2 coupons for the assign tests. +data TestParties = TestParties + with + governanceParty : Party + member1 : Party + member2 : Party + member3 : Party + outsider : Party + dso : Party + deriving (Show, Eq) + +-- | Allocate standard test parties. +allocateRewardsTestParties : Script TestParties +allocateRewardsTestParties = do + governanceParty <- allocateParty "GovernanceParty" + member1 <- allocateParty "Member1" + member2 <- allocateParty "Member2" + member3 <- allocateParty "Member3" + outsider <- allocateParty "Outsider" + dso <- allocateParty "DSO" + pure TestParties with .. + +-- | Get the standard member list (3 members). +getMembers : TestParties -> Set Party +getMembers parties = Set.fromList [parties.member1, parties.member2, parties.member3] + +-- | Create a RewardCouponV2 owned (provider) by `provider`, unassigned, as an +-- interface CID. Used by the coupon-reassignment-delegation tests. +mkUnassignedCoupon : Party -> Party -> Decimal -> Script (ContractId RewardCoupon) +mkUnassignedCoupon dso provider amount = do + now <- getTime + cid <- submit dso $ createCmd RewardCouponV2 with + dso + provider + round = Round 1 + amount + expiresAt = now `addRelTime` hours 36 + providerIsObserver = True + beneficiary = None + pure (toInterfaceContractId @RewardCoupon cid) + +-- | Build the submission context for a member acting with readAs on governance party. +-- Simulates the Canton topology where members host the governance party. +memberContext member gp = actAs member <> readAs gp + +-- | Create governance rules with standard test configuration. +-- 3 members, threshold of 2, 30 minute timeout. +createTestGovernance : TestParties -> Script (ContractId GovernanceRules) +createTestGovernance parties = + submit parties.governanceParty $ createCmd GovernanceRules with + governanceParty = parties.governanceParty + members = getMembers parties + threshold = 2 + actionConfirmationTimeout = minutes 30 + additionalProposers = None + +-- | Collect confirmations from a list of members for an interface action. +submitConfirmations : Party -> ContractId GovernanceRules -> ContractId GovernableAction -> [Party] -> Script [ContractId GovernanceConfirmation] +submitConfirmations governanceParty rulesCid proposalCid confirmers = + mapA (\confirmer -> do + result <- submit (memberContext confirmer governanceParty) $ + exerciseCmd rulesCid GovernanceRules_ConfirmAction with + confirmer + actionProposalCid = proposalCid + pure result.confirmationCid + ) confirmers + +-- | Confirm and execute an interface action in one go. +confirmAndExecute : TestParties -> ContractId GovernanceRules -> ContractId GovernableAction -> [Party] -> Party -> Script ExecuteConfirmedActionResult +confirmAndExecute parties rulesCid proposalCid confirmers executor = do + confirmationCids <- submitConfirmations parties.governanceParty rulesCid proposalCid confirmers + submit (memberContext executor parties.governanceParty) $ + exerciseCmd rulesCid GovernanceRules_ExecuteConfirmedAction with + executor + actionProposalCid = proposalCid + confirmations = confirmationCids diff --git a/daml/governance-rewards-assign-test/daml/Governance/Rewards/TestCouponReassignmentDelegation.daml b/daml/governance-rewards-assign-test/daml/Governance/Rewards/TestCouponReassignmentDelegation.daml new file mode 100644 index 00000000..efda8456 --- /dev/null +++ b/daml/governance-rewards-assign-test/daml/Governance/Rewards/TestCouponReassignmentDelegation.daml @@ -0,0 +1,309 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +module Governance.Rewards.TestCouponReassignmentDelegation where + +import Daml.Script +import Splice.Amulet (RewardCouponV2(..)) +import Splice.Api.RewardAssignmentV1 (RewardBeneficiary(..)) +import Governance.Rewards.CouponReassignmentDelegation +import Governance.Rewards.SetupCouponReassignmentDelegation +import Governance.Rewards.RevokeCouponReassignmentDelegation +import Governance.Rewards.AssignTestUtils -- allocateRewardsTestParties, TestParties (has .dso) + -- also: createTestGovernance, confirmAndExecute, + -- submitConfirmations, memberContext +import Governance.Action (GovernableAction) +import Governance.Rules (GovernanceRules, GovernanceRules_ExecuteConfirmedAction(..)) +import TestHarness -- Test{given,when,then_}, run, Failures, shouldBe + +-- mkUnassignedCoupon lives in AssignTestUtils (shared with the other reward tests). + +-- GWT fixture: parties + a delegation created DIRECTLY (in production it is created by +-- the SetupCouponReassignmentDelegation governance action). decparty is the +-- coupons' provider; assigners are the member parties (1-of-n). +data Fixture = Fixture with + parties : TestParties + split : [RewardBeneficiary] + delId : ContractId CouponReassignmentDelegation + +given_delegation : Script Fixture +given_delegation = do + parties <- allocateRewardsTestParties + let gp = parties.governanceParty + let split = [ RewardBeneficiary with beneficiary = parties.member2; percentage = 0.8 + , RewardBeneficiary with beneficiary = parties.member3; percentage = 0.2 ] + delId <- submit gp $ createCmd CouponReassignmentDelegation with + decparty = gp; dso = parties.dso; assigners = [parties.member1, parties.member2]; split + pure Fixture with .. + +-- shared no-assertion then_ for negative cases (when : Fixture -> Script ()) +then_nothing : Fixture -> a -> Script Failures +then_nothing _ _ = pure [] + +-- happy path: a SINGLE assigner (member1), acting alone, reassigns one coupon to the +-- baked-in split -> one RewardCouponV2 per beneficiary (original archived). +when_assigner_reassigns : Fixture -> Script Int +when_assigner_reassigns f = do + let gp = f.parties.governanceParty + c1 <- mkUnassignedCoupon f.parties.dso gp 100.0 + submit (memberContext f.parties.member1 gp) $ exerciseCmd f.delId Delegation_Assign with + assigner = f.parties.member1; primaryCoupon = c1; additionalCoupons = [] + coupons <- query @RewardCouponV2 gp + pure (length coupons) + +then_two_beneficiary_coupons : Fixture -> Int -> Script Failures +then_two_beneficiary_coupons _ n = pure $ shouldBe "one coupon per beneficiary" 2 n + +test_assigner_reassigns_to_baked_split = script do + run Test with + given = given_delegation + when = when_assigner_reassigns + then_ = then_two_beneficiary_coupons + +-- security: a non-assigner (member3, not in `assigners`) is rejected by the elem gate. +when_non_assigner_rejected : Fixture -> Script () +when_non_assigner_rejected f = do + let gp = f.parties.governanceParty + c1 <- mkUnassignedCoupon f.parties.dso gp 100.0 + -- member3 has the same readAs visibility a real member would; the rejection + -- must come from the `elem` gate, not from incidental lack of visibility. + submitMustFail (memberContext f.parties.member3 gp) $ exerciseCmd f.delId Delegation_Assign with + assigner = f.parties.member3; primaryCoupon = c1; additionalCoupons = [] + +test_non_assigner_cannot_reassign = script do + run Test with + given = given_delegation + when = when_non_assigner_rejected + then_ = then_nothing + +-- nonconsuming: the same delegation serves two rounds (2 coupons x 2 beneficiaries = 4). +when_two_rounds : Fixture -> Script Int +when_two_rounds f = do + let gp = f.parties.governanceParty + c1 <- mkUnassignedCoupon f.parties.dso gp 100.0 + c2 <- mkUnassignedCoupon f.parties.dso gp 50.0 + submit (memberContext f.parties.member1 gp) $ exerciseCmd f.delId Delegation_Assign with + assigner = f.parties.member1; primaryCoupon = c1; additionalCoupons = [] + submit (memberContext f.parties.member1 gp) $ exerciseCmd f.delId Delegation_Assign with + assigner = f.parties.member1; primaryCoupon = c2; additionalCoupons = [] + coupons <- query @RewardCouponV2 gp + pure (length coupons) + +then_four_beneficiary_coupons : Fixture -> Int -> Script Failures +then_four_beneficiary_coupons _ n = pure $ shouldBe "two rounds x two beneficiaries" 4 n + +test_delegation_is_nonconsuming = script do + run Test with + given = given_delegation + when = when_two_rounds + then_ = then_four_beneficiary_coupons + +-- revoke: the decparty archives the delegation. +when_revoke : Fixture -> Script Int +when_revoke f = do + submit f.parties.governanceParty $ exerciseCmd f.delId Delegation_Revoke + dels <- query @CouponReassignmentDelegation f.parties.governanceParty + pure (length dels) + +then_no_delegation : Fixture -> Int -> Script Failures +then_no_delegation _ n = pure $ shouldBe "delegation archived" 0 n + +test_revoke_archives = script do + run Test with + given = given_delegation + when = when_revoke + then_ = then_no_delegation + +-- already-assigned coupon rejected (all-or-nothing): re-assigning a consumed coupon +-- fails the whole tx. +when_reassign_same_coupon_fails : Fixture -> Script () +when_reassign_same_coupon_fails f = do + let gp = f.parties.governanceParty + c1 <- mkUnassignedCoupon f.parties.dso gp 100.0 + submit (memberContext f.parties.member1 gp) $ exerciseCmd f.delId Delegation_Assign with + assigner = f.parties.member1; primaryCoupon = c1; additionalCoupons = [] + -- c1 is now consumed/assigned; re-assigning the same cid fails the whole tx (all-or-nothing) + submitMustFail (memberContext f.parties.member1 gp) $ exerciseCmd f.delId Delegation_Assign with + assigner = f.parties.member1; primaryCoupon = c1; additionalCoupons = [] + +-- security: a coupon minted by a DIFFERENT dso is rejected. Any party can create +-- a RewardCouponV2 naming itself dso and this decparty as provider (dso is the +-- only signatory), so without this guard a stranger's coupon would be assignable +-- and, as a batch's primary, would make splice reject every genuine coupon +-- alongside it -- stopping the reward engine for the cost of one contract. +when_foreign_dso_coupon_rejected : Fixture -> Script () +when_foreign_dso_coupon_rejected f = do + let gp = f.parties.governanceParty + -- member3 mints it as its own dso, naming gp as provider. + bad <- mkUnassignedCoupon f.parties.member3 gp 100.0 + submitMustFail (memberContext f.parties.member1 gp) $ exerciseCmd f.delId Delegation_Assign with + assigner = f.parties.member1; primaryCoupon = bad; additionalCoupons = [] + +test_foreign_dso_coupon_rejected = script do + run Test with + given = given_delegation + when = when_foreign_dso_coupon_rejected + then_ = then_nothing + +test_already_assigned_coupon_rejected = script do + run Test with + given = given_delegation + when = when_reassign_same_coupon_fails + then_ = then_nothing + +-- ============================================================================ +-- Governance-path tests: the delegation is created/replaced/revoked via the +-- SetupCouponReassignmentDelegation / RevokeCouponReassignmentDelegation +-- GovernableActions, through a real propose -> confirm -> execute vote +-- via the governance action, rather than the direct-create fixture above. +-- ============================================================================ + +-- GWT fixture for the governance-created path: parties + a GovernanceRules + the split. +data GovFixture = GovFixture with + parties : TestParties + rulesCid : ContractId GovernanceRules + split : [RewardBeneficiary] + +given_governance : Script GovFixture +given_governance = do + parties <- allocateRewardsTestParties + rulesCid <- createTestGovernance parties + let split = [ RewardBeneficiary with beneficiary = parties.member2; percentage = 0.8 + , RewardBeneficiary with beneficiary = parties.member3; percentage = 0.2 ] + pure GovFixture with .. + +then_singleton : GovFixture -> Int -> Script Failures +then_singleton _ n = pure $ shouldBe "exactly one delegation" 1 n + +then_zero : GovFixture -> Int -> Script Failures +then_zero _ n = pure $ shouldBe "no delegation" 0 n + +then_gov_nothing : GovFixture -> () -> Script Failures +then_gov_nothing _ _ = pure [] + +-- one governance vote (propose -> confirm -> execute) creates the delegation +when_setup_executed : GovFixture -> Script [(ContractId CouponReassignmentDelegation, CouponReassignmentDelegation)] +when_setup_executed f = do + let gp = f.parties.governanceParty + actionCid <- submit f.parties.member1 $ createCmd SetupCouponReassignmentDelegation with + governanceParty = gp; proposer = f.parties.member1; priorDelegation = None + dso = f.parties.dso + assigners = [f.parties.member1, f.parties.member2]; beneficiaries = f.split + let iface : ContractId GovernableAction = toInterfaceContractId actionCid + _ <- confirmAndExecute f.parties f.rulesCid iface [f.parties.member1, f.parties.member2] f.parties.member1 + query @CouponReassignmentDelegation gp + +then_one_delegation_with_split : GovFixture -> [(ContractId CouponReassignmentDelegation, CouponReassignmentDelegation)] -> Script Failures +then_one_delegation_with_split f dels = + pure $ shouldBe "one delegation exists" 1 (length dels) + <> shouldBe "split baked in" [f.split] (map (\(_, d) -> d.split) dels) + +test_setup_creates_delegation = script do + run Test with + given = given_governance; when = when_setup_executed; then_ = then_one_delegation_with_split + +-- replace: a second Setup carrying priorDelegation archives the first (still singleton) +when_setup_replaces : GovFixture -> Script Int +when_setup_replaces f = do + let gp = f.parties.governanceParty + mk prior bene = do + a <- submit f.parties.member1 $ createCmd SetupCouponReassignmentDelegation with + governanceParty = gp; proposer = f.parties.member1; priorDelegation = prior + dso = f.parties.dso + assigners = [f.parties.member1, f.parties.member2] + beneficiaries = [RewardBeneficiary with beneficiary = bene; percentage = 1.0] + let iface : ContractId GovernableAction = toInterfaceContractId a + confirmAndExecute f.parties f.rulesCid iface [f.parties.member1, f.parties.member2] f.parties.member1 + _ <- mk None f.parties.member2 + [(oldCid, _)] <- query @CouponReassignmentDelegation gp + _ <- mk (Some oldCid) f.parties.member3 + dels <- query @CouponReassignmentDelegation gp + pure (length dels) + +test_setup_replaces_prior = script do + run Test with + given = given_governance; when = when_setup_replaces; then_ = then_singleton + +-- revoke via governance: the RevokeCouponReassignmentDelegation action archives it +when_revoke_via_governance : GovFixture -> Script Int +when_revoke_via_governance f = do + let gp = f.parties.governanceParty + s <- submit f.parties.member1 $ createCmd SetupCouponReassignmentDelegation with + governanceParty = gp; proposer = f.parties.member1; priorDelegation = None + dso = f.parties.dso + assigners = [f.parties.member1, f.parties.member2]; beneficiaries = f.split + _ <- confirmAndExecute f.parties f.rulesCid (toInterfaceContractId s : ContractId GovernableAction) + [f.parties.member1, f.parties.member2] f.parties.member1 + [(delCid, _)] <- query @CouponReassignmentDelegation gp + r <- submit f.parties.member1 $ createCmd RevokeCouponReassignmentDelegation with + governanceParty = gp; proposer = f.parties.member1; delegation = delCid + _ <- confirmAndExecute f.parties f.rulesCid (toInterfaceContractId r : ContractId GovernableAction) + [f.parties.member1, f.parties.member2] f.parties.member1 + dels <- query @CouponReassignmentDelegation gp + pure (length dels) + +test_revoke_via_governance = script do + run Test with + given = given_governance; when = when_revoke_via_governance; then_ = then_zero + +-- Reusable negative driver: propose a Setup carrying the given (bad) assigners / +-- beneficiaries, gather confirmations, and assert the governed execute fails an +-- executeImpl guard. The executor is a governance executor, NOT the delegation +-- assigner. Each `test_setup_*_rejected` below isolates one executeImpl guard. +setupExecuteMustFail : GovFixture -> [Party] -> [RewardBeneficiary] -> Script () +setupExecuteMustFail f assigners beneficiaries = do + let gp = f.parties.governanceParty + actionCid <- submit f.parties.member1 $ createCmd SetupCouponReassignmentDelegation with + governanceParty = gp; proposer = f.parties.member1; priorDelegation = None + dso = f.parties.dso + assigners; beneficiaries + let iface : ContractId GovernableAction = toInterfaceContractId actionCid + confs <- submitConfirmations gp f.rulesCid iface [f.parties.member1, f.parties.member2] + submitMustFail (actAs f.parties.member1 <> readAs gp) $ + exerciseCmd f.rulesCid GovernanceRules_ExecuteConfirmedAction with + executor = f.parties.member1 + actionProposalCid = iface + confirmations = confs + +rb : Party -> Decimal -> RewardBeneficiary +rb p pct = RewardBeneficiary with beneficiary = p; percentage = pct + +-- empty split rejected at execute (executeImpl asserts non-empty beneficiaries) +test_setup_empty_split_rejected = script do + run Test with + given = given_governance + when = \f -> setupExecuteMustFail f [f.parties.member1] [] + then_ = then_gov_nothing + +-- percentages not summing to 1.0 rejected at execute +test_setup_bad_sum_rejected = script do + run Test with + given = given_governance + when = \f -> setupExecuteMustFail f [f.parties.member1] [rb f.parties.member2 0.5] + then_ = then_gov_nothing + +-- a percentage outside (0,1] rejected at execute +test_setup_percentage_out_of_range_rejected = script do + run Test with + given = given_governance + when = \f -> setupExecuteMustFail f [f.parties.member1] [rb f.parties.member2 1.5] + then_ = then_gov_nothing + +-- a duplicated beneficiary rejected at execute. Percentages sum to 1.0 and are +-- each in range, so only the uniqueness guard can reject this. Without it the +-- delegation would be created, and every later Delegation_Assign would fail +-- splice's "Beneficaries are unique" requirement — stalling assignment behind a +-- coupon that can never be exercised. +test_setup_duplicate_beneficiary_rejected = script do + run Test with + given = given_governance + when = \f -> setupExecuteMustFail f [f.parties.member1] + [rb f.parties.member2 0.5, rb f.parties.member2 0.5] + then_ = then_gov_nothing + +-- more than 20 beneficiaries rejected at execute +test_setup_too_many_beneficiaries_rejected = script do + run Test with + given = given_governance + when = \f -> setupExecuteMustFail f [f.parties.member1] (replicate 21 (rb f.parties.member2 0.05)) + then_ = then_gov_nothing diff --git a/daml/governance-rewards-test/daml.yaml b/daml/governance-rewards-test/daml.yaml index 293db803..adaea65e 100644 --- a/daml/governance-rewards-test/daml.yaml +++ b/daml/governance-rewards-test/daml.yaml @@ -10,9 +10,9 @@ data-dependencies: - ../governance-action-v1/.daml/dist/governance-action-v1-0.1.0.dar - ../governance-core/.daml/dist/governance-core-v1-0.1.0.dar - ../governance-core-test/.daml/dist/governance-core-test-0.1.0.dar - - ../governance-rewards/.daml/dist/governance-rewards-v1-0.1.1.dar + - ../governance-rewards/.daml/dist/governance-rewards-v1-0.1.4.dar - ../dars/splice-wallet-0.1.18.dar - - ../dars/splice-amulet-0.1.17.dar + - ../dars/splice-amulet-0.1.19.dar - ../dars/testlib-0.1.0.dar build-options: - --target=2.2 diff --git a/daml/governance-rewards/README.md b/daml/governance-rewards/README.md index 6d964436..3940f58c 100644 --- a/daml/governance-rewards/README.md +++ b/daml/governance-rewards/README.md @@ -16,10 +16,13 @@ on-ledger actions the decparty needs to stand that up: |---|---| | `SetupMintingDelegation` | Create a `MintingDelegationProposal` naming a validator as the delegate that mints the decparty's coupons. | | `AcceptExternalPartySetup` | Accept a validator-created `ExternalPartySetupProposal`, creating the decparty's `ValidatorRight` + `TransferPreapproval` on that validator — the prerequisite that makes the validator actually run reward collection for the party. | +| `SetupCouponReassignmentDelegation` | Create (or atomically replace) a `CouponReassignmentDelegation` that carries the decparty's authority and a fixed beneficiary split; enables per-round, vote-free coupon reassignment. | +| `RevokeCouponReassignmentDelegation` | Archive the decparty's `CouponReassignmentDelegation`, disabling reassignment. | -Both are required to close the loop: `SetupMintingDelegation` establishes *who* -mints, `AcceptExternalPartySetup` gives the validator the standing to run its -collection automation for the party. +`SetupMintingDelegation` + `AcceptExternalPartySetup` close the *minting-delegation* +loop (Mode B): a validator mints the decparty's coupons. `SetupCouponReassignmentDelegation` +/ `RevokeCouponReassignmentDelegation` enable the *coupon-reassignment* loop (Mode A), +documented below. ## Prerequisites @@ -143,3 +146,84 @@ Establishing those is a two-step onboarding. 0.1.17-typed exercise run on the newer on-ledger contract; this was verified on devnet (the accept executed and minting followed). Re-confirm when the on-ledger splice-amulet version moves substantially ahead. + +## Coupon reassignment (Mode A) + +An alternative to minting delegation. Instead of a validator minting the +decparty's coupons, the decparty reassigns each coupon's `beneficiary` to a +governance-approved split; the beneficiaries then self-mint. This suits a +decparty that wants rewards routed to specific parties by a fixed split, without +a per-round governance vote. + +### Templates + +| Template | Role | +|---|---| +| `CouponReassignmentDelegation` | Standing delegation: `signatory decparty`, `observer assigners`, with the beneficiary `split` baked in. Created once by governance. | +| `SetupCouponReassignmentDelegation` | `GovernableAction` that creates or atomically replaces the delegation (the only reassignment step that takes a vote). | +| `RevokeCouponReassignmentDelegation` | `GovernableAction` that archives the delegation. | + +### How it works + +1. **Governance votes `SetupCouponReassignmentDelegation` once.** After threshold + confirmations, execution creates a `CouponReassignmentDelegation` naming the + member parties as `assigners` and carrying the fixed `split` + (`[{beneficiary, percentage}]`, percentages in (0,1], summing to 1.0, ≤ 20, + no duplicates). `executeImpl` re-validates the split, so a direct ledger + submit that bypasses the decman API cannot bake in a malformed split. +2. **Any single assigner reassigns each round — no vote.** The delegation's + `nonconsuming` `Delegation_Assign` choice is `controller assigner` gated by + `assert (assigner elem assigners)` (a genuine 1-of-n, not escalatable). It + reassigns caller-supplied coupons to the contract's `split` — + `newBeneficiaries = split`, **never** a caller argument (the security + boundary). The per-node `reward_automation` loop drives this on a timer. +3. **Beneficiaries self-mint** the resulting coupons before expiry (out of scope + for this plugin). + +### Trust model + +A single assigner (even the least-trusted member) can unilaterally reassign all +the decparty's unassigned coupons at any time — but only ever to the +governance-approved split. The blast radius is timing, not destination. +`Delegation_Revoke`/replace is `controller decparty`, i.e. governance-only. + +### Proposing / revoking + +```bash +# create or replace the delegation +curl -X POST http://:8080/governance/propose -H "Content-Type: application/json" -d '{ + "party_id": "::1220...", + "rules_contract_id": "", + "proposal": { + "type": "setup_coupon_reassignment_delegation", + "assigners": ["::1220...", "::1220..."], + "new_beneficiaries": [ + {"beneficiary": "::1220...", "percentage": "0.8"}, + {"beneficiary": "::1220...", "percentage": "0.2"} + ], + "prior_delegation": null + } +}' + +# revoke it +curl -X POST http://:8080/governance/propose -H "Content-Type: application/json" -d '{ + "party_id": "::1220...", + "rules_contract_id": "", + "proposal": { "type": "revoke_coupon_reassignment_delegation", "delegation": "" } +}' +``` + +- `assigners`: member parties allowed to run per-round reassignment (1-of-n). +- `new_beneficiaries`: the fixed split; validated at propose (decman) and at execute (DAML). +- `prior_delegation`: cid of the delegation to replace (`null` for the first create). + +### Caveats + +- **Mutually exclusive with Mode B.** Do not run coupon reassignment and + minting-delegation collection on the same decparty — they compete for the same + coupons. +- **Reassignment cadence.** The `reward_automation` loop assigns every + assignable coupon each tick, in chunks sized by output creates + (`reward_max_creates / beneficiary_count`). Throughput does not depend on the tick + interval, so the interval only trades assignment latency against transaction + cost. diff --git a/daml/governance-rewards/daml.yaml b/daml/governance-rewards/daml.yaml index 4640c2c1..93137e3a 100644 --- a/daml/governance-rewards/daml.yaml +++ b/daml/governance-rewards/daml.yaml @@ -1,6 +1,6 @@ sdk-version: 3.4.11 name: governance-rewards-v1 -version: 0.1.1 +version: 0.1.4 source: daml dependencies: - daml-prim @@ -8,7 +8,11 @@ dependencies: data-dependencies: - ../governance-action-v1/.daml/dist/governance-action-v1-0.1.0.dar - ../dars/splice-wallet-0.1.18.dar - - ../dars/splice-amulet-0.1.17.dar + # 0.1.19 (not 0.1.17) so Delegation_Assign can pin the coupon to the concrete + # RewardCouponV2 template; 0.1.17 predates it. + - ../dars/splice-amulet-0.1.19.dar + - ../dars/splice-api-reward-assignment-v1-1.0.0.dar + - ../dars/splice-api-token-metadata-v1-1.0.0.dar build-options: - --target=2.2 - --ghc-option=-Wunused-binds diff --git a/daml/governance-rewards/daml/Governance/Rewards/CouponReassignmentDelegation.daml b/daml/governance-rewards/daml/Governance/Rewards/CouponReassignmentDelegation.daml new file mode 100644 index 00000000..808e6137 --- /dev/null +++ b/daml/governance-rewards/daml/Governance/Rewards/CouponReassignmentDelegation.daml @@ -0,0 +1,70 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Standing delegation that carries the decparty's authority and the fixed +-- beneficiary split. Created ONCE via governance (SetupCouponReassignmentDelegation); +-- thereafter any single listed assigner may reassign a coupon batch to the baked-in +-- split with a plain Delegation_Assign exercise — no per-round vote (spec §5, §7, §12). +module Governance.Rewards.CouponReassignmentDelegation where + +import Splice.Amulet (RewardCouponV2) +import Splice.Api.RewardAssignmentV1 + (RewardCoupon, RewardBeneficiary, RewardCoupon_AssignBeneficiaries(..)) +import Splice.Api.Token.MetadataV1 + (ExtraArgs(..), emptyChoiceContext, emptyMetadata) + +-- `Splice.Api.Token.MetadataV1` exports no ready-made `emptyExtraArgs`; build it from +-- the exported primitives. +emptyExtraArgs : ExtraArgs +emptyExtraArgs = ExtraArgs with + context = emptyChoiceContext + meta = emptyMetadata + +template CouponReassignmentDelegation + with + decparty : Party -- = governanceParty = the coupons' provider + dso : Party -- the DSO whose coupons may be assigned; see Delegation_Assign + assigners : [Party] -- = all member parties; any one may reassign (1-of-n) + split : [RewardBeneficiary] -- BAKED IN; validated at create + where + signatory decparty + observer assigners + + -- Per-round reassignment. Nonconsuming: one delegation serves every round + -- until governance revokes/replaces it. + nonconsuming choice Delegation_Assign : () + with + assigner : Party -- the submitting member; checked below + primaryCoupon : ContractId RewardCoupon + additionalCoupons : [ContractId RewardCoupon] + controller assigner -- 1-of-n: any listed assigner, NOT a threshold + do + assertMsg "assigner must be listed on the delegation" (assigner `elem` assigners) + -- Pin the coupon before exercising the interface choice. Two distinct + -- guards, both required: + -- + -- 1. Fetching as splice's concrete RewardCouponV2 fails unless the + -- contract really is one. `RewardCoupon_AssignBeneficiaries` is + -- `controller (view this).provider`, so a look-alike implementation + -- whose view claims `provider = decparty` would otherwise satisfy + -- that controller with THIS delegation's decparty authority and run + -- its own code with it. + -- 2. A genuine RewardCouponV2 only needs its own `dso`'s authority to + -- create, so any party can mint one naming itself `dso` and this + -- decparty as `provider`. Such a coupon is a real RewardCouponV2 and + -- passes guard 1, but assigning it is not this delegation's business + -- and, as the batch's primary, it makes splice reject every genuine + -- coupon alongside it (they are fetched with the primary's `dso`). + coupon <- fetch (fromInterfaceContractId @RewardCouponV2 primaryCoupon) + assertMsg "coupon dso must match the delegation's dso" (coupon.dso == dso) + -- newBeneficiaries is the contract's `split`, NEVER a caller argument (security boundary). + _ <- exercise primaryCoupon RewardCoupon_AssignBeneficiaries with + additionalCoupons + newBeneficiaries = split + extraArgs = emptyExtraArgs + pure () + + -- Governance-only revoke (consuming → archives). Invoked via a GovernableAction. + choice Delegation_Revoke : () + controller decparty + do pure () diff --git a/daml/governance-rewards/daml/Governance/Rewards/RevokeCouponReassignmentDelegation.daml b/daml/governance-rewards/daml/Governance/Rewards/RevokeCouponReassignmentDelegation.daml new file mode 100644 index 00000000..46866efa --- /dev/null +++ b/daml/governance-rewards/daml/Governance/Rewards/RevokeCouponReassignmentDelegation.daml @@ -0,0 +1,28 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | GovernableAction that disables (archives) the delegation (spec §8, §11). +module Governance.Rewards.RevokeCouponReassignmentDelegation where + +import Governance.Rewards.CouponReassignmentDelegation +import Governance.Action + +template RevokeCouponReassignmentDelegation + with + governanceParty : Party + proposer : Party + delegation : ContractId CouponReassignmentDelegation + where + signatory proposer + observer governanceParty + + interface instance GovernableAction for RevokeCouponReassignmentDelegation where + view = GovernableActionView with + governanceParty + proposer + actionLabel = "RevokeCouponReassignmentDelegation" + description = "Revoke the coupon-reassignment delegation." + + executeImpl = do + _ <- exercise delegation Delegation_Revoke + pure () diff --git a/daml/governance-rewards/daml/Governance/Rewards/SetupCouponReassignmentDelegation.daml b/daml/governance-rewards/daml/Governance/Rewards/SetupCouponReassignmentDelegation.daml new file mode 100644 index 00000000..ef89381a --- /dev/null +++ b/daml/governance-rewards/daml/Governance/Rewards/SetupCouponReassignmentDelegation.daml @@ -0,0 +1,58 @@ +-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | GovernableAction that creates (or atomically replaces) the decparty's +-- CouponReassignmentDelegation. The ONLY reassignment step that takes a +-- governance vote (spec §5, §6, §8). Runs with governanceParty authority. +module Governance.Rewards.SetupCouponReassignmentDelegation where + +import DA.Foldable (forA_) +import DA.List (unique) +import Splice.Api.RewardAssignmentV1 (RewardBeneficiary(..)) +import Governance.Rewards.CouponReassignmentDelegation +import Governance.Action + +template SetupCouponReassignmentDelegation + with + governanceParty : Party + proposer : Party + priorDelegation : Optional (ContractId CouponReassignmentDelegation) + -- ^ cid of the delegation to replace (None for the first create); revoked at execute. + dso : Party + -- ^ the DSO whose coupons the delegation may assign. Fixed by this vote so + -- Delegation_Assign can reject a coupon minted by anyone else: any party can + -- create a RewardCouponV2 naming itself dso and this decparty as provider. + assigners : [Party] + beneficiaries : [RewardBeneficiary] + where + signatory proposer + observer governanceParty + + interface instance GovernableAction for SetupCouponReassignmentDelegation where + view = GovernableActionView with + governanceParty + proposer + actionLabel = "SetupCouponReassignmentDelegation" + description = "Create (or replace) the coupon-reassignment delegation." + + executeImpl = do + -- execute-time guards (a direct ledger submit bypasses the Rust boundary). + -- Same checks as the Rust validate_reward_beneficiaries + a non-empty assigners set. + assertMsg "beneficiaries must not be empty" (not (null beneficiaries)) + assertMsg "at most 20 beneficiaries" (length beneficiaries <= 20) + assertMsg "each percentage must be in (0,1]" + (all (\b -> b.percentage > 0.0 && b.percentage <= 1.0) beneficiaries) + -- Splice's RewardCoupon_AssignBeneficiaries requires unique beneficiaries, + -- so a duplicate baked in here would fail every later Delegation_Assign + -- and stall assignment behind a coupon that can never be exercised. + assertMsg "beneficiaries must be unique" (unique (map (.beneficiary) beneficiaries)) + let total = sum (map (.percentage) beneficiaries) + assertMsg "percentages must sum to 1.0" (total == 1.0) -- exact Decimal + assertMsg "assigners must not be empty" (not (null assigners)) + assertMsg "assigners must be unique" (unique assigners) + assertMsg "the decparty must not be its own assigner" + (governanceParty `notElem` assigners) + forA_ priorDelegation (`exercise` Delegation_Revoke) -- atomic replace when Some + _ <- create CouponReassignmentDelegation with + decparty = governanceParty; dso; assigners; split = beneficiaries + pure () diff --git a/daml/multi-package.yaml b/daml/multi-package.yaml index fc360216..2a5a7849 100644 --- a/daml/multi-package.yaml +++ b/daml/multi-package.yaml @@ -3,6 +3,7 @@ packages: - governance-core - governance-core-test - governance-rewards + - governance-rewards-assign-test - governance-rewards-test - governance-token-custody - governance-token-custody-test diff --git a/docs/coupon-reassignment/design.md b/docs/coupon-reassignment/design.md new file mode 100644 index 00000000..0821e887 --- /dev/null +++ b/docs/coupon-reassignment/design.md @@ -0,0 +1,306 @@ +# CIP-104 Coupon-Reassignment Automation (Mode A) — Design + +**Date:** 2026-07-14 (rev. 2026-07-20 — pivoted to the delegation model) +**Status:** Design — delegation model +**Author:** Gyorgy Balazsi (with Claude) +**Design doc lives in:** `cip-104` · **Implementation target:** `decentralization-manager` + +## In short + +A consortium of orgs is represented on-chain by a single **decentralized party id** (`cbtc-network::1220…`). Under CIP-104 it earns rewards as `RewardCouponV2` coupon contracts; the party credited on each coupon is its **`provider`** field, which here is that decparty id. + +But in that on-chain capacity the consortium has **no wallet** — it can act only through threshold governance, where the member orgs vote by hand. Coupons arrive every round and expire in ~36h, so there is no standing, per-round way to act on them, and the rewards expire uncollected. + +This closes the gap with a **delegation**. The consortium votes **once**, through normal threshold governance, to create an on-ledger `CouponReassignmentDelegation` contract that carries the decparty's own authority and has the beneficiary **split baked into it**. Thereafter, **any single member's node can assign each round's coupons directly** — a plain ledger command, no per-round vote — but *only* to that baked-in split, because the split is fixed in the contract and the caller cannot override it. + +The safety comes from the contract, not from trusting any node: the split is enforced **in DAML by construction (L3)**, so no node can misdirect rewards, and any one live member keeps assignment going (1-of-n liveness). One vote up front; no human clicks per round. + +## Glossary + +*Actor model (used throughout — see §2):* +- **Business entity (L1)** — the org, or group of orgs, that *makes decisions*. Off-chain. +- **Software agent (L2)** — the running software that *acts* for an entity: holds keys, submits transactions. A validator node or a wallet. +- **Party id (L3)** — the on-chain name (`foo::1220…`) that *represents* an entity, plus the on-ledger contracts that constrain what agents may do in its name. Passive: acted upon, never acting. + +*Canton / parties:* +- **Validator node** — a Canton participant node; generic infrastructure any org runs. "Validator" alone says nothing about whose it is or in what role. +- **Consortium member** — one of the m-of-n orgs that jointly govern a decentralized party. Each wears **two hats**: a *decision-maker* co-governing the decparty (a business entity, L1), and a *node operator* whose participant + DecMan instance is a software agent (L2) — here, the node that runs the automation. Same org, two layers. +- **Decentralized party (decparty)** — one party id collectively controlled by a consortium via an m-of-n threshold (here, `cbtc-network`). No single member can act for it alone. +- **Threshold governance (propose → confirm → execute)** — DecMan's on-ledger flow by which a consortium acts as its decparty: a member proposes an action, members confirm (each submitting a `GovernanceConfirmation`), and once ≥ threshold have confirmed, any member executes. Human-driven, hence slow — which is why this design uses it **once** (to create the delegation), not per round. + +*CIP-104 rewards:* +- **App provider (provider party)** — the party the DSO credits with app rewards. Here the decparty `cbtc-network` is its own provider. +- **`RewardCouponV2` / coupon** — a *minting right* (not CC) issued per round; must be redeemed before it expires (**TTL ≈ 36h**). +- **Beneficiary** — the party a coupon's value is assigned to; its agent mints the coupon. +- **Assign (`RewardCoupon_AssignBeneficiaries`)** — a choice the provider party controls that replaces an unassigned coupon with one coupon per beneficiary (each carrying its `beneficiary` + percentage); afterwards each beneficiary can mint its share. +- **Unassigned / reassignment (why this doc says both "assign" and "reassign").** A freshly-minted coupon is *unassigned* (`beneficiary = null`), which defaults its value to the **provider** — here the decparty, which cannot use it. This work **reassigns** the coupon away from that provider default to the governance-configured beneficiaries; that end-to-end routing is *coupon reassignment* (the product name). The on-ledger act that performs it is splice's `RewardCoupon_AssignBeneficiaries` choice, which *assigns beneficiaries* to a coupon. So throughout: **"assign" / "unassigned"** is the coupon-level, splice/ledger vocabulary (the `beneficiary` field), and **"reassignment"** is what this design does with it. +- **Self-mint** — a beneficiary's *agent* consuming its coupon to produce CC. Non-custodial: the CC lands with the beneficiary. +- **DSO** — the Splice super-validator collective that issues coupons / runs Amulet rules. +- **Effective split** — the resolved `{beneficiary → percentage}` set (for CBTC, an example configured value: operator 20% + `cbtc-beneficiary` 80%), baked into the `CouponReassignmentDelegation` (§8). + +*This design:* +- **`CouponReassignmentDelegation` ("the delegation")** — the on-ledger contract this doc introduces (§7): signed by the decparty, carrying the fixed split, observed by the member parties who may trigger assignment. Created once by a governance vote. +- **Assigner** — a member party listed on the delegation that may exercise `Delegation_Assign`. `assigners = all members`, so any **one** live member suffices (1-of-n). +- **`Delegation_Assign`** — the delegation's nonconsuming choice: an assigner supplies the target coupons; the choice assigns them to the **baked-in** split. The caller never supplies the beneficiaries — that is the security boundary (§12). +- **Coupon-reassignment automation ("the automation")** — the per-node background process this doc specifies (§9): read the active delegation + the decparty's unassigned coupons, and exercise `Delegation_Assign` on a due batch. Any member node runs it; running on several is safe (§10). +- **Mode A / Mode B** — two alternative reward-distribution approaches, **not** runtime-selectable via a mode flag. A = assign & self-mint (this doc), via the delegation. B = collect coupons into the decparty via a separate `MintingDelegation`/`AcceptExternalPartySetup` collection path (a one-shot, shipped as PR #256). There is no shared mode selector; a decparty runs one approach or the other — never both, since they compete for the same unassigned coupons. + +## 1. Context & goal + +Under CIP-104, a consortium acting as an app provider — its **decparty id** is the coupon's `provider` — earns traffic-based app rewards as `RewardCouponV2` coupons. Each coupon is a *minting right* that must be redeemed (or have beneficiaries assigned) before it expires — default TTL **36h**. + +The consortium cannot use the standard single-node wallet automation in its decparty capacity. Why is precise once you name the actors (§2): a wallet is a *single software agent* bound to a *single* entity's authority, but a decparty's authority is a **threshold over many agents**. So the members can act as the decparty only through slow threshold governance, with no standing, per-round way to act on the coupons. (You could call the reward engine a purpose-built, threshold-aware narrow subset of wallet functionality.) + +Verified on devnet (PQS `pqs_cbtc`, snapshot round 51961 / 2026-07-09): there are 118 `RewardCouponV2` coupons (~43,303 CC) with `provider = cbtc-network` (the CBTC decparty), all with `beneficiary = null` — **0 collected, all expired unclaimed**, and **0 coupons ever assigned to the configured beneficiary**. This is the gap this work addresses. + +**Goal:** the first production increment of the DecMan reward engine — **Mode A (assign & self-mint)**. The consortium votes **once** to create a `CouponReassignmentDelegation` that fixes the beneficiary split and carries the decparty's authority; thereafter per-node automation discovers the decparty's unassigned coupons and assigns them to that split via the delegation, so each beneficiary can mint its share. **The one governance vote replaces per-round voting entirely** — instead of an operator at each member org confirming every coupon batch (which could never keep pace with per-round coupons expiring in 36h), a single member's node assigns each batch directly, constrained by the delegation. Built to keep (code quality, tests), exercised on devnet. + +**Scope of "closing the gap" — read carefully.** This increment closes the *provider-side* gap: the decparty's coupons no longer expire *unassigned*. It does **not** by itself guarantee CC reaches beneficiaries — after assignment each beneficiary holds its own coupon, which **its software agent must mint before the coupon expires** (§2, §4.3). That minting is a precondition, not a deliverable here. So "reward realized end-to-end" = this increment **plus** each beneficiary's agent minting. + +Division of labour (settled in team coordination, 2026-07-20): there is **no** shared mode/config template and **no** per-decparty mode selection. Mode B is a **one-shot** collection path shipped separately (PR #256); this increment is the **Mode A automation**, whose configuration (the split) lives in the delegation contract it introduces (§8). A decparty runs one approach or the other, never both at once (they would compete for the same unassigned coupons). + +## 2. Actor model — the three layers (the backbone of this design) + +A Canton **party is not an active agent**. It is a passive on-chain representation. Every actor in this system decomposes into three layers, and keeping them distinct is what makes the design correct: + +1. **Business entity (L1)** — makes the decisions (an org, or a consortium). +2. **Software agent (L2)** — acts on the entity's behalf: holds credentials, authenticates, submits transactions. A validator node or a wallet. +3. **Party id (L3)** — the indirect on-chain representation of the entity, together with the on-ledger contracts that bind what any agent may do in its name. It *is acted upon*; it does not act. + +Mapping the parties in this design: + +| | Business entity (L1) | Software agent (L2) | Party id (L3) | +|---|---|---|---| +| **decparty** (`governanceParty` / `cbtc-network`) | the member **consortium** (m-of-n orgs) | the **DecMan mesh** — each member's DecMan + participant, coordinated by threshold governance (*not* a wallet) | `cbtc-network::…` — a *decentralized*-namespace party | +| **beneficiary** (`cbtc-beneficiary`, operator) | the org that owns the beneficiary (BitSafe; the operator) | that org's **node + wallet** (e.g. the `cbtc-beneficiary-minter` agent) | `cbtc-beneficiary::…` — a normal party | +| **member node** running the automation | each member org | its **DecMan instance** (the automation) | its member party (an assigner on the delegation) | + +Structural consequence: a **normal** party is 1 entity / 1 agent / 1 party id; a **decparty** is N entities (threshold) / N coordinated agents / 1 party id. This is why no single wallet can serve a decparty, and it dictates the central design principle: + +> **Never trust the agent (L2). Constrain it through the on-chain representation (L3), which encodes the entity's decision (L1).** Correctness and fairness must come from L3 artifacts that any L2 agent is forced to satisfy — not from an agent behaving well. + +The delegation model is the purest expression of this principle: the split is a field on an L3 contract, and the L2 assigner that triggers assignment **cannot supply or alter it** — the choice reads it from the contract. An agent can only trigger the pre-decided outcome, never choose a different one. + +**Writing rule for this doc:** a party id is never the grammatical subject of an action verb. An action is always done by an L2 *agent* (a node, a wallet, the automation) on behalf of an entity; "as the decparty" names the *capacity* an action is authorized in, not an actor. If a sentence says "the decparty does X," that's a bug in the sentence. + +## 3. Scope + +**In scope** +- A new DAML template `CouponReassignmentDelegation` (in the `governance-rewards` package): signed by the decparty, holding the fixed `[RewardBeneficiary]` split, the DSO party whose coupons it may assign, and the assigner list, with a `nonconsuming` `Delegation_Assign` choice that assigns caller-supplied coupons to the baked-in split (§7). +- A `GovernableAction` to **create** the delegation through governance — `SetupCouponReassignmentDelegation` — and a governance path to **revoke** it (archive). Creating/replacing the delegation is the *only* action that goes through a threshold vote. +- A new Rust background module in `crates/decman` — the automation — that, per node, reads the active delegation + the decparty's unassigned coupons, selects a due batch, and exercises `Delegation_Assign` as a plain ledger command. Each member node runs an instance. +- Batching cadence with a submission-safety expiry margin; duplicate/all-or-nothing handling (§10). +- Tests: DAML tests for the delegation and its choice, Rust unit tests, a devnet integration test. + +**Out of scope (future increments)** +- Mode B (the one-shot collection path, PR #256) — a separate, non-runtime-selectable approach owned by another workstream. +- Deterministic leader election + grace-window optimisation (cuts redundant assign attempts across a large fleet; no correctness/liveness role). +- Self-mint-as-a-service / grace-period sweeper for offline beneficiary agents. +- >20 beneficiaries (hierarchical reassignment / Merkle claim). +- Delegating any action other than coupon reassignment. + +## 4. Assumptions & preconditions + +Each is verified or flagged as a dependency. + +1. **`governanceParty` == app-provider party.** The delegation is signed by `governanceParty`; `Delegation_Assign` exercises `RewardCoupon_AssignBeneficiaries`, whose controller is the coupon's `provider`. The nested exercise only authorizes if the decparty's governance party *is* the provider party. **Verified for CBTC:** `InstrumentConfiguration` has `provider == registrar == cbtc-network`, and DecMan governs as `cbtc-network`. If a decparty's governance party ever differs from its provider party, this design does not apply unchanged. +2. **Enablement — the decparty has exactly one active `CouponReassignmentDelegation`.** There is no mode selector. The automation runs for a decparty **iff** that decparty has exactly one active delegation (presence = on; absence = skip; >1 = refuse and alert — see §11). Mode B (the one-shot collection path, PR #256) is a separate approach that must not run on the same decparty — the two would compete for the same unassigned coupons. +3. **Each beneficiary has a minting agent (L2).** Mode A's premise: after assignment, each beneficiary's software agent mints its coupon before expiry. Building that agent is out of scope; the beneficiary must already have one (its own wallet automation, or a `MintingDelegation` to a node that runs the collect-rewards trigger). Without it, coupons expire at the beneficiary instead of the provider. **Verified for CBTC (devnet PQS):** the configured beneficiaries are wallet-capable *normal* parties, not decparties, so there is no recursion of the "can't run a wallet" problem — `cbtc-beneficiary` (80%) lives under the single-node attestor-1/bitsafe namespace and has a dedicated `cbtc-beneficiary-minter` L2 agent; the operator (20%, `auth0_…`) is a standard validator wallet user. Still untested end-to-end, because on devnet the coupons expired *unassigned at the provider* — nothing ever reached the beneficiaries to exercise their agents. +4. **Devnet has the live CIP-104 V2 stack.** Verified in PQS: `RewardCouponV2`, `splice-api-reward-assignment-v1:RewardCoupon` (the assign interface), and `MintingDelegation` are all deployed. +5. **`splice-api-reward-assignment-v1` is available as a DAML build dependency** for the new package, matching the version live on the target network. **Resolved:** vendored (`splice-api-reward-assignment-v1-1.0.0` + `splice-amulet-0.1.19`) and verified against the target network — no longer an open assumption. +6. **The decparty is co-hosted on every member's participant.** Each member's participant hosts both that member's party *and* the decparty — this is how DecMan members already see governance contracts and confirm as the decparty. Three things this design relies on follow from it: (a) the decparty's unassigned coupons are visible in each member node's ACS to scan; (b) exercising `Delegation_Assign` from a member node has the decparty's signatory authority available locally (no cross-participant delegation dance); (c) the topology-confirmation floor for a `Delegation_Assign` transaction is just the decparty's normal hosting threshold (§11). **Verified for CBTC:** `cbtc-network` is hosted on the attestor participants that also host the member parties (attestor-1/attestor-2 active). + +## 5. Why a delegation + +**The shape of the problem.** Acting as the decparty requires the decparty's authority, which is a threshold over the member agents (§2). There are two ways to give per-round coupon assignment that authority: + +1. **Vote every round.** Treat each assignment as a governance action: propose, gather ≥ threshold confirmations, execute — once per coupon batch. This keeps the decision fresh but puts a recurring, time-critical quorum on the 36h critical path: assignment stalls whenever fewer than threshold member nodes confirm a given round in time. +2. **Vote once, into a contract.** Make the *recurring* decision — "assign each round's coupons to this split" — a single governance action that creates a standing on-ledger contract carrying the decparty's authority, with the split fixed inside it. Each round is then a plain command any one member can issue against that contract. + +**This design takes (2): a delegation.** The recurring policy (the split) is decided once, by the full threshold; the recurring *mechanical act* (assign this batch) needs no further consensus, because the contract has already constrained it to exactly one outcome. This is (1) with the quorum moved off the per-round critical path and onto the one-time setup. + +**Why it is safe.** The split is a field on the delegation, and `Delegation_Assign` uses that field — never a caller argument (§7, §12). So an assigner, acting with only its own single-member authority plus the decparty authority the delegation lends it, can assign coupons **only to the pre-voted split**. Fairness is enforced in **L3 by construction**, not by trusting any L2 agent — the §2 principle, made structural rather than procedural. + +That safety depends on the coupon being what it claims to be, which is **not** free. `RewardCoupon_AssignBeneficiaries` is `controller (view this).provider`, and the choice takes an *interface* contract id, so a template whose view merely claims `provider = decparty` would satisfy that controller using the decparty authority this delegation lends — and then run its own code with it. `Delegation_Assign` therefore **fetches the primary coupon as splice's concrete `RewardCouponV2`** before exercising, which fails for any other implementation. Separately, `dso` is `RewardCouponV2`'s only signatory, so **any party can mint a genuine coupon naming itself `dso` and this decparty as `provider`**; the delegation carries the DSO party fixed by the same vote and rejects a coupon from any other, because as a batch's primary such a coupon makes splice reject every genuine coupon alongside it (they are fetched with the primary's `dso`). With both guards, a compromised or buggy node's worst case is assigning the correct split at an inconvenient time, or wasting a transaction. + +**Why it is live.** `assigners = all members`, and the choice is `1-of-n` (any single assigner controls it — *not* a threshold). Assignment proceeds as long as **one** member node is up and the protocol-level topology threshold for the `Delegation_Assign` transaction is met (§11) — that topology confirmation is automatic, not a vote. There is no per-round governance quorum to miss. + +**What changes the policy.** Because the split is baked in, changing it is not a config edit — it is a new governance vote that **archives the old delegation and creates a new one** (§8). There is no separate mutable split store to drift out of sync, and no window in which a stale split and a live delegation disagree. + +## 6. Architecture overview + +Two phases on top of existing DecMan machinery (propose/confirm/execute engine, per-node member credentials, ACS/PQS queries, `tokio::spawn` background tasks): + +``` + ── PHASE 1: SETUP (once; the only threshold vote) ───────────────── + A member proposes SetupCouponReassignmentDelegation(split, assigners); + members confirm; any member executes. Execute creates, with the + decparty's authority: + + CouponReassignmentDelegation + signatory decparty (carries decparty authority) + observer assigners (= all members) + split [RewardBeneficiary] ← BAKED IN, the security boundary + + ── PHASE 2: PER-ROUND ASSIGN (every tick; no vote) ──────────────── + Per-node automation (a member node's L2 agent — a background task). + Every member node runs this each tick; running on several is safe (§10): + + 1. read the one active CouponReassignmentDelegation (skip if none) + 2. scan unassigned RewardCouponV2 (provider = decparty, beneficiary = null) + 3. select every assignable coupon (a small margin before expiry) + 4. exercise Delegation_Assign(assigner = self, primaryCoupon, additionalCoupons) + │ controller = this node's member party (1-of-n) + ▼ + Delegation_Assign (nonconsuming; split is read from the contract) + │ lends the decparty's signatory authority + ▼ + RewardCoupon_AssignBeneficiaries(coupons, split) ← controller = provider = decparty + │ + ▼ + one coupon per beneficiary → each beneficiary's own L2 agent mints it +``` + +**6.1 Existing pieces reused** +- **One-time governance setup:** `submit_proposal` + `resolve_active_governance_rules` (governance rules cid + the **governance** threshold from the active `GovernanceRules` contract, `handlers/governance.rs:115–138` — *not* the topology threshold `get_party_threshold` returns) + `execute_confirm_action` + `execute_action`, driven by the node's stored member credentials (`get_party_credentials`). This is Phase 1 only; Phase 2 uses none of it. +- **Decoded ACS reads:** `active_created_records` (decoded `GetActiveContracts`, following the `fetch_proposal_infos` decode pattern) — used to read the active delegation and the unassigned coupons. (Not `query_contracts_by_template`, which is blob-only, nor `get_contracts`, which is metadata-only.) The coupon interface is `#splice-api-reward-assignment-v1:Splice.Api.RewardAssignmentV1:RewardCoupon`. +- **Plain command submission:** the per-round `Delegation_Assign` exercise is an ordinary ledger command from the executing node's member credentials — no governance round. + +**6.2 New action plumbing (Phase 1 only).** Creating the delegation is a `GovernableAction`, so it needs the usual closed-enum touch points: a `ProposalType::SetupCouponReassignmentDelegation` variant + `validate()` arm + `action_serializer` mapping + `ProposalPackage` + handler package-id mapping + `PackageConfig` field. Revocation reuses the same machinery. `SetProviderAppRewardBeneficiaries` (`types.rs:627`, `action_serializer.rs:1163`) and PR #256's `SetupMintingDelegation` are worked examples of exactly these touch points. **The per-round assign needs none of this** — it is a plain exercise, not a governance action. + +**6.3 Beneficiary minting (L2, not us).** Assignment produces one coupon per beneficiary. Each beneficiary's **software agent** then mints it (its wallet's collect-rewards trigger, or a `MintingDelegation`-based one). This is downstream of this increment (§4.3). + +## 7. DAML: `CouponReassignmentDelegation` + +New module `Governance/Rewards/CouponReassignmentDelegation.daml` in the `governance-rewards` package (alongside PR #256's `Governance/Rewards/SetupMintingDelegation.daml`). The delegation carries the decparty's authority and the fixed split; a nonconsuming choice lets any assigner assign coupons to that split. + +```haskell +template CouponReassignmentDelegation + with + decparty : Party -- = governanceParty = the coupons' provider + assigners : [Party] -- = all member parties; any one may assign (1-of-n) + split : [RewardBeneficiary] -- BAKED IN; percentages in (0,1] summing to 1.0, ≤ maxNumNewBeneficiaries + where + signatory decparty + observer assigners + + -- Per-round assignment. Non-consuming: one delegation serves every round + -- until governance revokes/replaces it. + nonconsuming choice Delegation_Assign : () + with + assigner : Party -- the submitting member; checked against `assigners` + primaryCoupon : ContractId RewardCoupon -- splice-api-reward-assignment-v1 + additionalCoupons : [ContractId RewardCoupon] -- batch the rest in one tx + controller assigner -- the caller authorizes as `assigner`; 1-of-n, NOT a threshold + do + -- 1-of-n gate: the caller must be a listed assigner. Any single one suffices. + assert (assigner `elem` assigners) + -- The caller supplies ONLY the coupons + its own party. `newBeneficiaries` is read + -- from the contract's `split` field — never from the caller. This is the security boundary. + _ <- exercise primaryCoupon RewardCoupon_AssignBeneficiaries with + additionalCoupons + newBeneficiaries = split + extraArgs = emptyExtraArgs -- locally built (MetadataV1 exports none): ExtraArgs { emptyChoiceContext, emptyMetadata } + pure () + + -- Governance-only revoke (archive). Replacing the split = revoke + re-create (§8). + choice Delegation_Revoke : () + controller decparty + do pure () +``` + +*(1-of-n is realized the standard DAML way: the choice takes the caller's own party as `assigner`, `controller assigner` requires that party to authorize the exercise, and `assert (assigner `elem` assigners)` restricts it to a listed member. Any single listed member suffices — it is a disjunction, not a threshold. The caller cannot escalate by passing another member's party: `controller assigner` means the submitter must actually authorize *as* that party.)* + +Notes: +- **Authority flow.** Exercising `Delegation_Assign` makes available the union of the contract's signatory authority (`decparty`) and the choice controller's authority (one assigner). The nested `RewardCoupon_AssignBeneficiaries` requires the provider's authority (= `decparty`), which the delegation's signatory supplies. So a **single** member, with only its own authority as controller, can trigger assignment — but only to the baked-in split. +- **`RewardCoupon_AssignBeneficiaries`** requires each coupon to have **no** assigned beneficiary and caps beneficiaries at `maxNumNewBeneficiaries` (≤20); it validates percentages in (0,1] summing to 1.0. The split is validated once at delegation-create time too (see below), so a bad split can never be baked in. +- **Creation authority.** `signatory decparty` means the delegation can only be created with the decparty's authority — obtained through Phase-1 `GovernableAction_Execute` (controller = `governanceParty` = `decparty`). It cannot be created by any single member outside governance. +- **Revocation & replacement** are governance actions (`Delegation_Revoke`, controlled by `decparty`); the per-round choice never archives the delegation (nonconsuming), so one vote serves indefinitely. +- **No expiry guard in DAML, deliberately.** `Delegation_Assign` does not `assert` anything about a coupon's remaining TTL. Withholding a nearly-expired coupon guarantees nobody ever mints it, whereas assigning it late still lets the beneficiary try — so a minimum TTL is not a property worth enforcing on-ledger, and baking one into a signed template would remove that option permanently and need a package upgrade to change. The Rust-side margin (§9) exists only to keep a coupon that may vanish mid-submission out of an all-or-nothing chunk, which is a client scheduling concern. Contrast the split, which *is* DAML-enforced because it is a genuine trust boundary. +- **DAR dependency:** `splice-api-reward-assignment-v1` in `daml.yaml`. +- **Review gate:** `Delegation_Assign` is authority-carrying DAML — `newBeneficiaries = split` (not a caller argument) is the entire security model. It gets a security-focused review before merge (§13). + +## 8. Effective split & where it lives (baked into the delegation) + +The split is an **L3 artifact that encodes the L1 decision** — this is where fairness is enforced, per §2. Contrast Mode B, where a fairness-relevant business decision — *which delegate* — stays off-chain as a free-text note the code can't act on. Mode A puts its fairness-relevant decision, the split, **on-chain and immutable within a delegation**, which is exactly what lets it be *enforced by construction* (the choice reads it; a caller cannot override it) rather than *trusted*. The rationale for a particular split (why 20/80) remains an L1 fact and lives off-chain; only the split itself is on-ledger, for enforcement. + +**Where it lives.** The split is a `[RewardBeneficiary { beneficiary; percentage }]` field on the `CouponReassignmentDelegation`, percentages summing to 1.0, validated once when the delegation is created (Rust `validate()` at the API boundary — §9 — and the DAML choice at execute). There is **no separate mutable split store**: baking the split into the delegation means there is nothing to keep in sync with it and no window in which a live delegation and a separate config disagree. + +**The split is whatever governance configures** — there is no weight composition and no operator-cut derivation. If the DSO's (or an operator's) cut is part of the arrangement, it is just another configured `RewardBeneficiary` entry; the automation does not compute it. Percentages are stored directly, so nothing is normalized at read time. + +**Changing the split (or the assigner set)** is a new governance vote: `Delegation_Revoke` the old delegation and `SetupCouponReassignmentDelegation` a new one. Where the governance flow lets one action archive-and-create in a single transaction, the swap is atomic (never zero or two active). Otherwise it is two separate votes, and the order determines the transient the automation must tolerate: revoke-then-create leaves a brief **zero**-delegation window (automation no-ops), create-then-revoke a brief **two**-delegation window (automation refuses — §11). Either way the automation never assigns to an ambiguous or stale split, because the old delegation's split is fixed and its archival removes it entirely. Membership changes are handled the same way, since `assigners` is likewise baked in. + +The **effective split** for CBTC today is, as an example configured value, **operator 20% + cbtc-beneficiary 80%** — the two `RewardBeneficiary` entries (0.2 and 0.8) that would be baked into the delegation. (The earlier `getBeneficiaries` weight-composition from utility-registry `InstrumentConfiguration` + `AppRewardConfiguration` is not used.) + +## 9. Rust automation module (a member node's L2 agent) + +New module `crates/decman/src/server/reward_automation/` (mirrors existing background-task style; registered via `tokio::spawn` in `start_server`, like the Canton sync loop). Cadence is a single global tick interval, `NodeConfig.reward_automation_interval_secs` (env `DECPM_REWARD_AUTOMATION_INTERVAL_SECS`, default 300s). + +**Config / gating** +- Enablement is the **presence of exactly one active `CouponReassignmentDelegation`** for the decparty (§8) — there is no mode gate. None ⇒ no-op (log and skip); more than one ⇒ refuse (log and alert, §11). The split is read from that contract, not from node config. +- **Not** gated on an active `FeaturedAppRight`: the FAR governs whether *new* coupons accrue, not whether *existing* unassigned coupons can be assigned. The automation acts on any live unassigned coupon whose `provider` is the decparty, even if the FAR has lapsed. +- Uses the node's existing member credentials; no new secrets, no off-ledger split. + +**Per-tick loop** (runs on every member node; running on several is safe — see §10): +1. **Read the active delegation.** Load `CouponReassignmentDelegation` for the decparty via `active_created_records`. Zero ⇒ no-op; more than one ⇒ refuse + alert. This node's member party must appear in `assigners` (else it cannot assign — log and skip). +2. **Query unassigned coupons:** active `RewardCouponV2` where `provider = decparty` and `beneficiary = null`, ordered by `expiresAt`. (Minted/consumed coupons archive out of the ACS, so "active + beneficiary = null" suffices — no separate "already assigned" bookkeeping.) +3. **Select the assignable coupons** (`select_assignable`): every unassigned coupon is eligible as soon as we see it, except those within `reward_min_expiry_margin_secs` of expiry. That margin guards **our own submission** — `Delegation_Assign` is all-or-nothing, so a coupon expiring between the ACS read and the commit fails its whole chunk — and is sized to submission latency (default 2 min), **not** to reserve minting time for the beneficiary: withholding a coupon guarantees nobody mints it, while assigning it late still lets the beneficiary try, since the per-beneficiary coupons inherit the original expiry. For the same reason there is deliberately **no minimum-age gate**: assignment neither consumes the coupon nor shortens its life. Selection does **not** truncate — see step 4. +4. **Assign the whole set, in chunks.** A tick drains everything it selected, submitting successive `Delegation_Assign` transactions, rather than assigning one batch and waiting for the next tick. This keeps **throughput independent of the tick interval**, so the interval is a latency/cost knob rather than a safety-critical one, and a backlog cannot grow just because the interval is long. The chunk bounds one *transaction*, not a tick's work: it is sized in **output creates** (`chunk_size = reward_max_creates / beneficiary_count`, config-tunable), because one assign creates `coupons × beneficiaries` contracts — a fixed coupon count would be `beneficiary_count`-times looser for a wide split than a narrow one. Coupons go most-urgent-first, so any partial failure sheds the coupons with the most time left. Cadence: a periodic tick (a few times/day), not per-round — coupons can be assigned any time before mint/expiry, which minimizes tx cost. +5. **Submit each chunk:** exercise `Delegation_Assign { assigner = this node's member party, primaryCoupon, additionalCoupons }` on the delegation, as a **plain ledger command** from this node's member credentials. No proposal, no confirmation, no execute round. On success the coupons leave the unassigned scan and the tick moves to the next chunk. **A chunk that fails ends the tick.** Every assign failure in practice means this node's view is stale — another assigner committed first, or the coupon expired (§10) — and only a fresh read fixes that, which the next tick performs. Retrying smaller cannot help, because a smaller chunk is not a newer view; skipping the coupon and continuing would grind through failure after failure, because an assigner that took the first coupon has very likely taken the rest of the batch. Chunks already committed stand, and the coupons left behind keep their full TTL, so the cost is at most one interval of latency. + +**Setup / revoke path (Phase 1, invoked out-of-band, not on the tick).** Creating or replacing the delegation goes through the reused governance flow (§6.1): `SetupCouponReassignmentDelegation` / revoke as `GovernableAction`s. `validate()` checks the split at the API boundary — non-empty beneficiary set, percentages in (0,1] summing to 1.0 (exact `DamlDecimal`), ≤ `maxNumNewBeneficiaries` (reuses `validate_reward_beneficiaries`) — so a bad split fails fast instead of wasting a governance round or baking in an invalid contract. + +## 10. Coordination & idempotency + +`RewardCoupon_AssignBeneficiaries` is **all-or-nothing and not idempotent**: a coupon MUST NOT already have a beneficiary, so assigning an already-assigned/archived coupon **fails the whole transaction**. The design turns this into a safety property: + +- **Any assigner may assign** (no elected assigner → no single-node liveness dependency; 1-of-n). +- **Duplicate/overlapping assigns are safe, just wasteful.** If two nodes assign overlapping batches in the same tick, the first to commit assigns; the second finds those coupons already assigned (or archived) and its exercise fails harmlessly, changing nothing. Because assignment is all-or-nothing per *batch*, a single contended coupon wastes that node's whole batch for the tick — but every coupon it did not get to is still unassigned, so the next scan picks it up and the loop converges. Correctness never depends on coordination. +- **Best-effort duplicate suppression (tx-cost only):** a node may skip coupons it has just assigned in-process, but no cross-node coordination is required or attempted. +- **Assigned coupons drop out of the next scan** (they gain a beneficiary or archive), so the loop converges without external state. +- A deterministic **leader + grace-window** is a follow-up that only cuts wasted assign attempts under large fleets — no correctness or liveness role, since any node assigning is already safe and live. + +## 11. Error handling & edge cases + +- **Coupon expires before assignment commits:** it leaves the ACS; the `Delegation_Assign` exercise for a chunk containing it fails cleanly (all-or-nothing); the tick ends and the next one re-reads, picking up the survivors. Assigning on first sight leaves the whole 36h TTL as margin, and the expiry margin (§9, step 3) keeps a coupon that is about to vanish out of a chunk in the first place. +- **A coupon that can never be exercised** (a package-version mismatch being the realistic case) heads the deterministic most-urgent-first order on every tick, so it ends every tick and assignment stalls behind it. This is a **known, accepted exposure**: skipping the coupon would not repair it — the coupon is lost either way — and would hide the cause, so the failure is surfaced with the coupon id for alerting and human diagnosis instead. Detecting it is the reward-automation health signal tracked in issue #278. +- **Coupon assigned by another node mid-tick:** same all-or-nothing failure; harmless. The tick ends and the next one re-reads a fresh view, rather than retrying a set another node has already taken. +- **No active delegation:** no-op with a clear log (automation is off for that decparty until governance creates one). +- **More than one active delegation:** refuse and alert (do **not** guess which split is authoritative). This occurs only transiently, during a create-then-revoke replacement (§8), or from a governance error; the automation stays off until exactly one is active. +- **This node not in `assigners`:** cannot assign; log and skip. Liveness still holds as long as one listed assigner's node is up. +- **Transient delegation-view skew during replacement:** while the split is being changed (§8), the automation sees zero or two delegations and no-ops/refuses rather than assigning to an ambiguous split. It never assigns to a stale split, because the old delegation's `split` is fixed and its archival removes it entirely. +- **Chunk size:** bounded by output creates, not a fixed coupon count (§9, step 4), and operator-tunable (`reward_max_creates`) because the ledger's transaction ceiling is unmeasured. A tick drains its whole assignable set, so a long interval cannot by itself build a backlog. +- **Beneficiary count:** capped by `maxNumNewBeneficiaries` (≤20); CBTC has 2, well within. >20 is out of scope (§3). +- **A node being down:** fewer assigners available. Others still assign; assignment proceeds while **one** listed assigner's node is live *and* the protocol-level topology threshold for the `Delegation_Assign` transaction is met (the decparty is hosted on N participants; each hosting participant confirms per Canton's rules). That topology bound is the decparty's normal liveness floor and is unchanged by this design — but note it is a *protocol-level* threshold on the hosting participants, distinct from the *governance* threshold that only Phase-1 setup needs. + +## 12. Trust & security properties + +Stated in the §2 frame — **fairness/correctness live in L3, never in L2 trust**: + +- **No agent is trusted for the split.** The split is a field on the delegation and `Delegation_Assign` reads it directly (`newBeneficiaries = split`); the caller supplies only coupon ids. A compromised or buggy assigner cannot exfiltrate or misdirect rewards — the worst it can do is assign the correct split at an inconvenient time or waste a transaction. Fairness is an L3 invariant enforced **by construction**, not a runtime check any node could skip. +- **Setup takes the full threshold; only setup does.** The delegation can only be created (or replaced/revoked) by a `GovernableAction` reaching the governance threshold. A single member cannot create, alter, or widen a delegation. Per-round assignment needs no vote precisely *because* the one-time vote already fixed the only degree of freedom. +- **1-of-n liveness, m-of-n control.** Any single assigner's node can trigger assignment — the recurring per-round *governance* quorum is gone; only the decparty's normal, automatic topology-confirmation floor remains (§11), which every action already pays. Changing *what* gets assigned still takes the full committee. Control and liveness are decoupled — the point of the design. +- **Non-custodial.** CC never lands with the decparty; assignment routes each coupon to a beneficiary party, whose own agent mints it. The automation moves no funds. +- **No standing off-ledger secret.** The split lives on-ledger; the automation reads it, never a node config file. +- **The choice is the security boundary.** `Delegation_Assign` is authority-carrying DAML and gets a security-focused review (§13); its correctness — that `newBeneficiaries` is the contract field, never a caller argument, and the controller is a 1-of-n assigner rather than an open party — *is* the trust model. + +## 13. Testing + +- **DAML** (for `CouponReassignmentDelegation`): happy path (an assigner assigns a batch to the baked-in split); **caller cannot alter the split** (the choice takes no beneficiary argument — asserted structurally, and the produced coupons carry exactly `split`); a non-assigner party cannot exercise `Delegation_Assign`; already-assigned coupon rejected (all-or-nothing); batching via `additionalCoupons`; the delegation can only be created with the decparty's authority (not a lone member); `Delegation_Revoke` archives it and is controlled by the decparty; nonconsuming — the delegation survives repeated assigns. Split validation (percentages in (0,1] summing to 1.0, ≤ cap, non-empty) at create. Written in the repo's `TestHarness` given/when/then harness (as `governance-rewards-test` does), not plain `Daml.Script`. +- **Rust unit:** `ProposalType::SetupCouponReassignmentDelegation` serialization round-trip (copy the `build_proposal_*_shape` test shape); `validate()` rejects empty set / bad percentages / >cap (exact `DamlDecimal`, via `validate_reward_beneficiaries`); selection (`select_assignable` — expiry margin, most-urgent-first, no truncation, and a coupon kept even when the beneficiary may lack time to mint) and chunk sizing (`chunk_size` — creates-based, never 0); the per-tick gating (zero delegations → no-op; two → refuse; node-not-assigner → skip). +- **Integration (devnet):** against live `cbtc-network` coupons — create the delegation via governance (one vote), then a **single** member node exercises `Delegation_Assign` and we assert the beneficiaries' coupons appear with the expected split. (Beneficiary minting is a separate precondition, asserted only if those agents run.) This requires only **one** member node to assign — there is no per-round multi-node confirmation to exercise. Gated behind `DECPM_IT_REWARD`; extends the existing devnet IT phase. Note (devnet reality): `cbtc-network`'s coupons are currently swept to 0 by the Mode-B collection path, so the IT needs that paused (§ handover / team coordination). + +## 14. Decisions (resolved in team coordination, 2026-07-20) + +1. **Model:** the **delegation model** — one governance vote creates a `CouponReassignmentDelegation` with the split baked in; per-round assignment is a plain 1-of-n exercise, no per-round vote. The split is enforced in L3 DAML by construction, and the recurring quorum is off the 36h critical path — only the one-time setup needs the governance threshold. +2. **Config source (§8):** "Option B" — a governance-config artifact — realized as the split **baked into the delegation contract**, not a separate mutable split store. **No `getBeneficiaries` composition and no operator-cut derivation** — "the split is whatever governance configures," so any DA/operator cut is just another configured entry. +3. **Package placement** of `CouponReassignmentDelegation` (and the `SetupCouponReassignmentDelegation` / revoke actions): the `governance-rewards` package. +4. **Mode selector:** none. Mode A is built standalone; Mode B is a **one-shot** collection path (PR #256), not a runtime-selectable engine mode. There is no shared mode/config template and no per-decparty mode flag; the two must not run on the same decparty. + +## 15. Milestone breakdown (proposed) + +- **M1 — DAML delegation + tests:** `CouponReassignmentDelegation` (template + `Delegation_Assign` + `Delegation_Revoke`) in the `governance-rewards` package + `daml.yaml` dep on `splice-api-reward-assignment-v1` + the DAML tests in §13. Reviewable in isolation; the `Delegation_Assign` security review lands here. +- **M2 — Rust setup plumbing:** `ProposalType::SetupCouponReassignmentDelegation` (+ revoke) variant + `validate()` + `action_serializer` mapping + `ProposalPackage`/handler/`PackageConfig`; serialization + validation unit tests. Mirrors PR #256. Enables creating/replacing the delegation through governance. +- **M3 — per-round automation:** the background loop — read active delegation, scan unassigned coupons, `select_assignable` (expiry margin), drain the set in creates-sized chunks via `Delegation_Assign` as a plain command; per-tick gating; Rust unit tests. +- **M4 — devnet integration:** end-to-end — governance creates the delegation, a single member node assigns live `cbtc-network` coupons; IT test (needs Mode-B collection paused on `cbtc-network`). +- **M5 (future):** leader/grace-window; reconciliation/metrics; beneficiary self-mint enablement; Mainnet gating. diff --git a/integration-tests/common.sh b/integration-tests/common.sh index 369b00e5..504d52f9 100644 --- a/integration-tests/common.sh +++ b/integration-tests/common.sh @@ -187,6 +187,7 @@ start_nodes() { DECPM_CANTON_NETWORK=devnet \ DECPM_NOISE_PORT="${noise_ports[$idx]}" \ DECPM_PORT="${http_ports[$idx]}" \ + DECPM_REWARD_AUTOMATION_INTERVAL_SECS=15 \ "$BINARY" -d "$DEV_DIR/participant-$i" serve \ >> "$log_file" 2>&1 & PIDS+=($!) diff --git a/releases/v1/governance-rewards-v1-0.1.2.dar b/releases/v1/governance-rewards-v1-0.1.2.dar new file mode 100644 index 00000000..14242ad5 Binary files /dev/null and b/releases/v1/governance-rewards-v1-0.1.2.dar differ diff --git a/releases/v1/governance-rewards-v1-0.1.3.dar b/releases/v1/governance-rewards-v1-0.1.3.dar new file mode 100644 index 00000000..79d09b8d Binary files /dev/null and b/releases/v1/governance-rewards-v1-0.1.3.dar differ diff --git a/releases/v1/governance-rewards-v1-0.1.4.dar b/releases/v1/governance-rewards-v1-0.1.4.dar new file mode 100644 index 00000000..93ea1bd4 Binary files /dev/null and b/releases/v1/governance-rewards-v1-0.1.4.dar differ