Skip to content

Commit b01a505

Browse files
committed
add total weight changes
1 parent 71aabfe commit b01a505

9 files changed

Lines changed: 96 additions & 28 deletions

File tree

programs/core-attribute-voter/README.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ Core Attribute Voter allows DAOs to use NFT collections for governance voting wh
1212

1313
Each configured collection specifies:
1414
- A **weight attribute key** (e.g. `"voting_power"`, `"tier"`) — the attribute name to read from each NFT
15-
- A **max weight** — a ceiling that caps any single NFT's voting power and represents the collection's max governance weight
15+
- A **max weight** — a ceiling that caps any single NFT's voting power
16+
- A **total weight** — the collection's contribution to the quorum denominator (`max_voter_weight`)
1617
- An **expected attribute authority** — the trusted authority that set the attributes
1718

1819
When a voter submits their NFTs, the program:
@@ -32,20 +33,21 @@ When a voter submits their NFTs, the program:
3233
The maximum possible voting power across all configured collections:
3334

3435
```
35-
max_voter_weight = Σ collection.max_weight
36+
max_voter_weight = Σ collection.total_weight
3637
```
3738

38-
This is used by SPL Governance to calculate quorum thresholds. Unlike the nft-voter and core-voter plugins (where max weight = `collection_size × weight_per_nft`), attribute-based voting has variable per-NFT weights, so `max_weight` must be set by the realm authority to reflect the expected total voting power of each collection.
39+
This is used by SPL Governance to calculate quorum thresholds. Unlike the nft-voter and core-voter plugins (where max weight = `collection_size × weight_per_nft`), attribute-based voting has variable per-NFT weights, so `total_weight` must be set by the realm authority to reflect the expected total voting power of each collection.
3940

40-
#### Setting `max_weight` correctly
41+
#### Setting `max_weight` and `total_weight` correctly
4142

42-
`max_weight` plays a dual role — it caps individual NFT weights **and** feeds into the quorum denominator. Getting it right matters:
43+
- `max_weight` caps per-NFT voting power.
44+
- `total_weight` controls quorum denominator contribution for the collection.
4345

4446
**Example 1 — Well-calibrated:**
4547
A collection of 50 NFTs where attributes range from 1–10, totalling ~200 across the collection.
4648
Setting `max_weight = 200` means:
4749
- Individual NFTs are capped at 200 (effectively uncapped since max attribute is 10)
48-
- Quorum denominator reflects the true total voting power
50+
- If `total_weight = 200`, quorum denominator reflects the true total voting power
4951
- A 60% quorum requires 120 voting power to pass
5052

5153
**Example 2 — Set too low:**
@@ -61,8 +63,8 @@ Same collection, but `max_weight = 10000`.
6163
- Risk: quorum becomes unreachable since the collection only holds ~200 total power
6264

6365
**Example 4 — Multiple collections:**
64-
Collection A: `max_weight = 500`, Collection B: `max_weight = 300`.
65-
- `max_voter_weight = 500 + 300 = 800`
66+
Collection A: `max_weight = 500`, `total_weight = 500`; Collection B: `max_weight = 300`, `total_weight = 300`.
67+
- `max_voter_weight = 500 + 300 = 800` (sum of `total_weight`)
6668
- A voter holding NFTs from both collections accumulates weight across them
6769
- 60% quorum requires 480 total voting power
6870

@@ -103,6 +105,7 @@ create_max_voter_weight_record()
103105
104106
configure_collection(
105107
max_weight: 100,
108+
total_weight: 1000,
106109
weight_attribute_key: "voting_power",
107110
expected_attribute_authority: UpdateAuthority
108111
)
@@ -162,7 +165,8 @@ relinquish_nft_vote()
162165

163166
| Parameter | Type | Constraints | Description |
164167
|---|---|---|---|
165-
| `max_weight` | `u64` | > 0 | Max governance weight for the collection. Caps individual NFT weights and is summed across collections for quorum calculation. Should reflect the expected total voting power of the collection. |
168+
| `max_weight` | `u64` | > 0 | Max governance weight per NFT (attribute cap per asset). |
169+
| `total_weight` | `u64` | > 0 | Collection's total governance contribution for quorum calculation. Summed across collections into `max_voter_weight`. |
166170
| `weight_attribute_key` | `String` | 1–32 characters | Attribute name to read from NFTs |
167171
| `expected_attribute_authority` | `PluginAuthority` | Must match plugin | Trusted authority for attribute validation |
168172

programs/core-attribute-voter/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ pub enum CoreNftAttributeVoterError {
1212
#[msg("Invalid max weight, must be greater than 0")]
1313
InvalidMaxWeight,
1414

15+
#[msg("Invalid total weight, must be greater than 0")]
16+
InvalidTotalWeight,
17+
1518
#[msg("Invalid MaxVoterWeightRecord Realm")]
1619
InvalidMaxVoterWeightRecordRealm,
1720

programs/core-attribute-voter/src/instructions/configure_collection.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ pub struct ConfigureCollection<'info> {
4949
pub fn configure_collection(
5050
ctx: Context<ConfigureCollection>,
5151
max_weight: u64,
52+
total_weight: u64,
5253
weight_attribute_key: String,
5354
expected_attribute_authority: PluginAuthority,
5455
) -> Result<()> {
@@ -71,6 +72,11 @@ pub fn configure_collection(
7172
CoreNftAttributeVoterError::InvalidMaxWeight
7273
);
7374

75+
require!(
76+
total_weight > 0,
77+
CoreNftAttributeVoterError::InvalidTotalWeight
78+
);
79+
7480
// Validate weight_attribute_key
7581
require!(
7682
!weight_attribute_key.is_empty() && weight_attribute_key.len() <= 32,
@@ -80,6 +86,7 @@ pub fn configure_collection(
8086
let collection_config = CollectionConfig {
8187
collection: collection_key,
8288
max_weight,
89+
total_weight,
8390
weight_attribute_key,
8491
expected_attribute_authority,
8592
reserved: [0; 8],

programs/core-attribute-voter/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,12 @@ pub mod core_attribute_voter {
5252
pub fn configure_collection(
5353
ctx: Context<ConfigureCollection>,
5454
max_weight: u64,
55+
total_weight: u64,
5556
weight_attribute_key: String,
5657
expected_attribute_authority: mpl_core::types::PluginAuthority,
5758
) -> Result<()> {
5859
log_version();
59-
instructions::configure_collection(ctx, max_weight, weight_attribute_key, expected_attribute_authority)
60+
instructions::configure_collection(ctx, max_weight, total_weight, weight_attribute_key, expected_attribute_authority)
6061
}
6162

6263
pub fn cast_nft_vote<'a, 'b, 'c, 'info>(

programs/core-attribute-voter/src/state/collection_config.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ pub struct CollectionConfig {
88
pub collection: Pubkey,
99

1010
/// Maximum governance power weight of the collection
11-
/// Serves as both the per-NFT weight cap and the quorum denominator contribution
12-
/// (i.e. it is summed across collections to produce MaxVoterWeightRecord.max_voter_weight)
13-
/// Should be set to the expected total voting power of the collection,
14-
/// in the same unit/scale as the attribute values stored on the NFTs
11+
/// Any NFT whose configured attribute value exceeds this cap is limited to this value.
1512
pub max_weight: u64,
1613

14+
/// Total governance power contribution of the collection to quorum denominator.
15+
/// This value is summed across collections to produce MaxVoterWeightRecord.max_voter_weight.
16+
pub total_weight: u64,
17+
1718
/// The attribute key to read the voting weight from on each NFT
1819
/// The attribute value must be a valid u64 string
1920
pub weight_attribute_key: String,
@@ -27,8 +28,9 @@ pub struct CollectionConfig {
2728
}
2829

2930
impl CollectionConfig {
30-
/// Borsh serialized size: 32 (Pubkey) + 8 (u64) + 4+32 (String with max 32 chars) + 33 (PluginAuthority) + 8 (reserved)
31-
pub const SERIALIZED_SIZE: usize = 32 + 8 + 36 + 33 + 8;
31+
/// Borsh serialized size: 32 (Pubkey) + 8 (max_weight) + 8 (total_weight)
32+
/// + 4+32 (String with max 32 chars) + 33 (PluginAuthority) + 8 (reserved)
33+
pub const SERIALIZED_SIZE: usize = 32 + 8 + 8 + 36 + 33 + 8;
3234

3335
}
3436

@@ -37,6 +39,7 @@ impl Default for CollectionConfig {
3739
Self {
3840
collection: Pubkey::default(),
3941
max_weight: 0,
42+
total_weight: 0,
4043
// Default to a 32-byte zero-padded string for deterministic sizing
4144
weight_attribute_key: "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0".to_string(),
4245
expected_attribute_authority: PluginAuthority::Address { address: Pubkey::default() },

programs/core-attribute-voter/src/state/registrar.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ impl Registrar {
6868
self.collection_configs
6969
.iter()
7070
.try_fold(0u64, |sum, cc| {
71-
sum.checked_add(cc.max_weight)
71+
sum.checked_add(cc.total_weight)
7272
.ok_or_else(|| CoreNftAttributeVoterError::ArithmeticOverflow.into())
7373
})
7474
}

programs/core-attribute-voter/tests/configure_collection.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async fn test_configure_collection() -> Result<(), TransportError> {
6262
assert_eq!(max_voter_weight_record.max_voter_weight_expiry, None);
6363
assert_eq!(
6464
max_voter_weight_record.max_voter_weight,
65-
registrar.collection_configs[0].max_weight
65+
registrar.collection_configs[0].total_weight
6666
);
6767

6868
Ok(())
@@ -505,6 +505,43 @@ async fn test_configure_collection_with_empty_weight_attribute_key_error(
505505
Ok(())
506506
}
507507

508+
#[tokio::test]
509+
async fn test_configure_collection_with_zero_total_weight_error(
510+
) -> Result<(), TransportError> {
511+
// Arrange
512+
let mut core_voter_test = CoreVoterTest::start_new().await;
513+
514+
let realm_cookie = core_voter_test.governance.with_realm().await?;
515+
516+
let registrar_cookie = core_voter_test.with_registrar(&realm_cookie).await?;
517+
518+
let collection_cookie = core_voter_test.core.create_collection(Some(1)).await?;
519+
520+
let max_voter_weight_record_cookie = core_voter_test
521+
.with_max_voter_weight_record(&registrar_cookie)
522+
.await?;
523+
524+
// Act
525+
let err = core_voter_test
526+
.with_collection(
527+
&registrar_cookie,
528+
&collection_cookie,
529+
&max_voter_weight_record_cookie,
530+
Some(ConfigureCollectionArgs {
531+
total_weight: Some(0),
532+
..Default::default()
533+
}),
534+
)
535+
.await
536+
.err()
537+
.unwrap();
538+
539+
// Assert
540+
assert_nft_voter_err(err, CoreNftAttributeVoterError::InvalidTotalWeight);
541+
542+
Ok(())
543+
}
544+
508545
#[tokio::test]
509546
async fn test_configure_collection_with_too_long_weight_attribute_key_error(
510547
) -> Result<(), TransportError> {

programs/core-attribute-voter/tests/program_test/core_voter_test.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ pub struct CollectionConfigCookie {
5757

5858
pub struct ConfigureCollectionArgs {
5959
pub max_weight: u64,
60+
pub total_weight: Option<u64>,
6061
pub weight_attribute_key: String,
6162
pub expected_attribute_authority: mpl_core::types::PluginAuthority,
6263
}
@@ -65,6 +66,7 @@ impl Default for ConfigureCollectionArgs {
6566
fn default() -> Self {
6667
Self {
6768
max_weight: 1,
69+
total_weight: None,
6870
weight_attribute_key: "weight".to_string(),
6971
expected_attribute_authority: mpl_core::types::PluginAuthority::UpdateAuthority,
7072
}
@@ -522,10 +524,12 @@ impl CoreVoterTest {
522524
signers_override: Option<&[&Keypair]>,
523525
) -> Result<CollectionConfigCookie, BanksClientError> {
524526
let args = args.unwrap_or_default();
527+
let total_weight = args.total_weight.unwrap_or(args.max_weight);
525528

526529
let data =
527530
anchor_lang::InstructionData::data(&gpl_core_attribute_voter::instruction::ConfigureCollection {
528531
max_weight: args.max_weight,
532+
total_weight,
529533
weight_attribute_key: args.weight_attribute_key.clone(),
530534
expected_attribute_authority: args.expected_attribute_authority.clone(),
531535
});
@@ -556,6 +560,7 @@ impl CoreVoterTest {
556560
let collection_config = CollectionConfig {
557561
collection: collection_cookie.collection,
558562
max_weight: args.max_weight,
563+
total_weight,
559564
weight_attribute_key: args.weight_attribute_key,
560565
expected_attribute_authority: args.expected_attribute_authority,
561566
reserved: [0; 8],

programs/core-attribute-voter/tests/update_max_voter_weight_record.rs

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ async fn test_update_collection_config_invalidates_max_voter_weight_record_expir
2222
.await?;
2323

2424
let collection_1_size = 7;
25-
let collection_1_weight = 5;
25+
let collection_1_max_weight = 5;
26+
let collection_1_total_weight = 40;
2627

2728
let collection_cookie_1 = core_voter_test
2829
.core
@@ -36,7 +37,8 @@ async fn test_update_collection_config_invalidates_max_voter_weight_record_expir
3637
&collection_cookie_1,
3738
&max_voter_weight_record_cookie,
3839
Some(ConfigureCollectionArgs {
39-
max_weight: collection_1_weight,
40+
max_weight: collection_1_max_weight,
41+
total_weight: Some(collection_1_total_weight),
4042
..Default::default()
4143
}),
4244
)
@@ -48,7 +50,8 @@ async fn test_update_collection_config_invalidates_max_voter_weight_record_expir
4850
.await?;
4951

5052
let collection_2_size = 10;
51-
let collection_2_weight = 2;
53+
let collection_2_max_weight = 2;
54+
let collection_2_total_weight = 30;
5255

5356
// Generate a new collection and update the registrar with the additional collection
5457
// while invalidating max voter weight.
@@ -64,7 +67,8 @@ async fn test_update_collection_config_invalidates_max_voter_weight_record_expir
6467
&collection_cookie_2,
6568
&max_voter_weight_record_cookie,
6669
Some(ConfigureCollectionArgs {
67-
max_weight: collection_2_weight,
70+
max_weight: collection_2_max_weight,
71+
total_weight: Some(collection_2_total_weight),
6872
..Default::default()
6973
}),
7074
)
@@ -83,7 +87,7 @@ async fn test_update_collection_config_invalidates_max_voter_weight_record_expir
8387
let _clock = core_voter_test.bench.get_clock().await;
8488

8589
// Assert
86-
let max_voter_weight_total = collection_1_weight + collection_2_weight;
90+
let max_voter_weight_total = collection_1_total_weight + collection_2_total_weight;
8791

8892
assert!(registrar.collection_configs.len() == 2);
8993
assert!(max_voter_weight_record.max_voter_weight_expiry.is_none());
@@ -110,7 +114,8 @@ async fn test_update_max_voter_weight_record_provides_valid_expirey() -> Result<
110114

111115
// Set collection sizes and weights for collection_1
112116
let collection_1_size = 11;
113-
let collection_1_weight = 4;
117+
let collection_1_max_weight = 4;
118+
let collection_1_total_weight = 44;
114119

115120
let collection_cookie_1 = core_voter_test
116121
.core
@@ -124,7 +129,8 @@ async fn test_update_max_voter_weight_record_provides_valid_expirey() -> Result<
124129
&collection_cookie_1,
125130
&max_voter_weight_record_cookie,
126131
Some(ConfigureCollectionArgs {
127-
max_weight: collection_1_weight,
132+
max_weight: collection_1_max_weight,
133+
total_weight: Some(collection_1_total_weight),
128134
..Default::default()
129135
}),
130136
)
@@ -145,7 +151,8 @@ async fn test_update_max_voter_weight_record_provides_valid_expirey() -> Result<
145151
// which also invalidates max_voter_weight_expirey.
146152

147153
let collection_2_size = 9;
148-
let collection_2_weight = 3;
154+
let collection_2_max_weight = 3;
155+
let collection_2_total_weight = 27;
149156
let collection_cookie_2 = core_voter_test
150157
.core
151158
.create_collection(Some(collection_2_size))
@@ -158,7 +165,8 @@ async fn test_update_max_voter_weight_record_provides_valid_expirey() -> Result<
158165
&collection_cookie_2,
159166
&max_voter_weight_record_cookie,
160167
Some(ConfigureCollectionArgs {
161-
max_weight: collection_2_weight,
168+
max_weight: collection_2_max_weight,
169+
total_weight: Some(collection_2_total_weight),
162170
..Default::default()
163171
}),
164172
)
@@ -180,7 +188,7 @@ async fn test_update_max_voter_weight_record_provides_valid_expirey() -> Result<
180188
.await;
181189

182190
// Assert
183-
let max_voter_weight_total = collection_1_weight + collection_2_weight;
191+
let max_voter_weight_total = collection_1_total_weight + collection_2_total_weight;
184192

185193
assert!(registrar.collection_configs.len() == 2);
186194
assert!(max_voter_weight_record.max_voter_weight == max_voter_weight_total as u64);

0 commit comments

Comments
 (0)