Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion crates/decman/src/server/action_serializer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ 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,
};

// ============================================================================
Expand Down Expand Up @@ -328,6 +328,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)),
Expand Down Expand Up @@ -1184,6 +1191,41 @@ pub fn build_proposal_create_args(
],
},
),
ProposalType::AssignRewardBeneficiaries {
primary_coupon,
additional_coupons,
new_beneficiaries,
} => (
ProposalPackage::GovernanceRewards,
"Governance.Rewards.AssignRewardBeneficiaries",
"AssignRewardBeneficiaries",
Record {
record_id: None,
fields: vec![
field("governanceParty", make_party(governance_party)),
field("proposer", make_party(proposer)),
field("primaryCoupon", make_contract_id(primary_coupon)),
field(
"additionalCoupons",
make_list(
additional_coupons
.iter()
.map(|cid| make_contract_id(cid))
.collect(),
),
),
field(
"newBeneficiaries",
make_list(
new_beneficiaries
.iter()
.map(serialize_reward_beneficiary)
.collect(),
),
),
],
},
),
ProposalType::SetEnableResultContracts {
registrar_service_cid,
enable_result_contracts,
Expand Down Expand Up @@ -2056,6 +2098,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 {
Expand Down Expand Up @@ -2430,6 +2477,50 @@ mod tests {
Ok(())
}

#[test]
fn build_proposal_assign_reward_beneficiaries_shape() -> Result {
let proposal = ProposalType::AssignRewardBeneficiaries {
primary_coupon: "0089abc-c1".to_string(),
additional_coupons: vec!["0089abc-c2".to_string()],
new_beneficiaries: vec![
RewardBeneficiary {
beneficiary: party_id(),
percentage: dec("0.8"),
},
RewardBeneficiary {
beneficiary: party_id(),
percentage: dec("0.2"),
},
],
};
let (package, module, entity, record) =
build_proposal_create_args("gov", "proposer", &proposal, None, None)?;

assert_eq!(package, ProposalPackage::GovernanceRewards);
assert_eq!(module, "Governance.Rewards.AssignRewardBeneficiaries");
assert_eq!(entity, "AssignRewardBeneficiaries");
assert_eq!(
owned_labels(&record),
[
"governanceParty",
"proposer",
"primaryCoupon",
"additionalCoupons",
"newBeneficiaries",
]
);
// additionalCoupons is a list of one; newBeneficiaries is a list of two records
assert!(matches!(
field_value(&record, "additionalCoupons").sum,
Some(value::Sum::List(_)),
));
assert!(matches!(
field_value(&record, "newBeneficiaries").sum,
Some(value::Sum::List(_)),
));
Ok(())
}

#[test]
fn build_proposal_accept_transfer_shape_and_context_branches() -> Result {
let proposal = ProposalType::AcceptTransfer {
Expand Down
109 changes: 109 additions & 0 deletions crates/decman/src/server/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down Expand Up @@ -630,6 +640,15 @@ pub enum ProposalType {
#[serde(default)]
provider_app_reward_beneficiaries: Option<Vec<AppRewardBeneficiary>>,
},
/// Assign governance-configured beneficiaries to the decparty's CIP-104
/// reward coupons via `RewardCoupon_AssignBeneficiaries`. governanceParty
/// == the coupons' provider (spec §4.1). Percentages sum to 1.0, <= 20
/// entries.
AssignRewardBeneficiaries {
primary_coupon: String,
additional_coupons: Vec<String>,
new_beneficiaries: Vec<RewardBeneficiary>,
},
/// Toggle result-contract emission on a `RegistrarService`.
SetEnableResultContracts {
registrar_service_cid: String,
Expand Down Expand Up @@ -763,11 +782,47 @@ impl ProposalType {
provider_app_reward_beneficiaries: Some(beneficiaries),
..
} => validate_beneficiary_weights(beneficiaries),
ProposalType::AssignRewardBeneficiaries {
new_beneficiaries, ..
} => validate_reward_beneficiaries(new_beneficiaries),
_ => Ok(()),
}
}
}

/// Validates `AssignRewardBeneficiaries::new_beneficiaries`: non-empty,
/// <= 20 entries, each percentage in (0.0, 1.0], summing to exactly 1.0.
///
/// `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::<DamlDecimal>()
.expect("'0' is a valid DamlDecimal");
let one: DamlDecimal = "1".parse().expect("'1' is a valid DamlDecimal");
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
));
}
}
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))]
Expand Down Expand Up @@ -1417,4 +1472,58 @@ 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 assign_reward_beneficiaries_validate_rejects_empty_beneficiaries() {
let p = ProposalType::AssignRewardBeneficiaries {
primary_coupon: "0089abc".to_string(),
additional_coupons: vec![],
new_beneficiaries: vec![],
};
assert!(p.validate().is_err());
}

#[test]
fn assign_reward_beneficiaries_validate_rejects_bad_percentages() {
// sum != 1.0
let p = ProposalType::AssignRewardBeneficiaries {
primary_coupon: "0089abc".to_string(),
additional_coupons: vec![],
new_beneficiaries: vec![rb("alice", "0.5")],
};
assert!(p.validate().is_err());
// percentage out of (0,1]
let p2 = ProposalType::AssignRewardBeneficiaries {
primary_coupon: "0089abc".to_string(),
additional_coupons: vec![],
new_beneficiaries: vec![rb("alice", "0.0"), rb("bob", "1.0")],
};
assert!(p2.validate().is_err());
}

#[test]
fn assign_reward_beneficiaries_validate_accepts_valid() {
let p = ProposalType::AssignRewardBeneficiaries {
primary_coupon: "0089abc".to_string(),
additional_coupons: vec!["0089def".to_string()],
new_beneficiaries: vec![rb("alice", "0.8"), rb("bob", "0.2")],
};
assert!(p.validate().is_ok());
}
}
Binary file added daml/dars/splice-amulet-0.1.19.dar
Binary file not shown.
Binary file not shown.
22 changes: 22 additions & 0 deletions daml/governance-rewards-assign-test/daml.yaml
Original file line number Diff line number Diff line change
@@ -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.0.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
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
-- Copyright (c) 2026 DLC-Link, Inc. and/or its affiliates. All rights reserved.
-- SPDX-License-Identifier: Apache-2.0

-- | Test utilities for AssignRewardBeneficiaries 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 (minutes)
import Daml.Script

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]

-- | 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
Loading
Loading