From 7ba79a9ba5e6dc759b9072a1d3e41592835abdaf Mon Sep 17 00:00:00 2001 From: connelblaze Date: Tue, 28 Jul 2026 07:10:34 +0100 Subject: [PATCH 1/2] add a get_owner_weights returning a full list --- contracts/accord/src/lib.rs | 20 +++++++++++ contracts/accord/src/test.rs | 66 ++++++++++++++++++++++++++++++++++++ docs/CONTRACT_API.md | 17 ++++++++++ 3 files changed, 103 insertions(+) diff --git a/contracts/accord/src/lib.rs b/contracts/accord/src/lib.rs index 296dd53..8916b52 100644 --- a/contracts/accord/src/lib.rs +++ b/contracts/accord/src/lib.rs @@ -87,6 +87,13 @@ pub struct ProposalApprovalProgress { pub total_weight: u32, } +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct OwnerWeight { + pub owner: Address, + pub weight: u32, +} + #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] pub struct ProposalCreatedEvent { @@ -2022,6 +2029,19 @@ impl AccordContract { Ok(read_owners_map(&env)?.keys()) } + /// Returns every current owner's address paired with their voting weight, + /// in a single call. The sum of the returned weights equals the current + /// total-weight counter. Read-only; no authorization required. + pub fn get_owner_weights(env: Env) -> Result, ContractError> { + let owners = read_owners_map(&env)?; + let mut result = Vec::new(&env); + for owner in owners.keys().iter() { + let weight = owners.get(owner.clone()).unwrap_or(0); + result.push_back(OwnerWeight { owner, weight }); + } + Ok(result) + } + /// Returns the spending limit for an (owner, token) pair, or `None` if no /// limit is set (the owner is unrestricted for that token). pub fn get_spending_limit(env: Env, owner: Address, token: Address) -> Option { diff --git a/contracts/accord/src/test.rs b/contracts/accord/src/test.rs index 331722e..3ea9f21 100644 --- a/contracts/accord/src/test.rs +++ b/contracts/accord/src/test.rs @@ -5609,6 +5609,72 @@ fn get_owner_weight_returns_owner_not_found_for_non_owner() { assert_eq!(client.get_owner_weight(&owner_b), 1); } +// ─── get_owner_weights ──────────────────────────────────────────────────── + +/// Confirms get_owner_weights returns every owner with the correct weight +/// for a multisig with several owners holding different weights, and that +/// the sum of returned weights matches the total-weight counter. +#[test] +fn get_owner_weights_returns_all_owners_with_correct_weights() { + let (env, client, owner_a, owner_b, owner_c, token_client) = + setup_three_owner_weighted([5, 3, 2], 8); + + let result = client.get_owner_weights(); + + assert_eq!(result.len(), 3); + + let mut sum: u32 = 0; + for entry in result.iter() { + match entry.owner { + _ if entry.owner == owner_a => assert_eq!(entry.weight, 5), + _ if entry.owner == owner_b => assert_eq!(entry.weight, 3), + _ if entry.owner == owner_c => assert_eq!(entry.weight, 2), + _ => panic!("unexpected owner in result"), + } + sum = sum.checked_add(entry.weight).unwrap(); + } + + assert_eq!(sum, client.get_total_weight()); +} + +/// After adding and then removing an owner, get_owner_weights must reflect +/// the current set and the total-weight counter must still match. +#[test] +fn get_owner_weights_reflects_owner_changes() { + let (env, client, owner_a, owner_b, owner_c, non_owner, token_client) = setup(2); + + // Initial: 3 owners each weight 1, total_weight = 3. + let result = client.get_owner_weights(); + assert_eq!(result.len(), 3); + let mut sum: u32 = 0; + for entry in result.iter() { + assert_eq!(entry.weight, 1); + sum = sum.checked_add(entry.weight).unwrap(); + } + assert_eq!(sum, 3); + + // Add non_owner as a fourth owner (weight 1 by default). + let add_id = client.create_add_owner_proposal( + &owner_a, + &non_owner, + &str(&env, "Add fourth owner"), + &DEADLINE, + ); + client.approve(&owner_a, &add_id); + client.approve(&owner_b, &add_id); + client.execute(&owner_c, &add_id); + + let result = client.get_owner_weights(); + assert_eq!(result.len(), 4); + let mut sum: u32 = 0; + for entry in result.iter() { + assert_eq!(entry.weight, 1); + sum = sum.checked_add(entry.weight).unwrap(); + } + assert_eq!(sum, 4); + assert_eq!(sum, client.get_total_weight()); +} + // ─── Issue #320: total-weight overflow rejection ───────────────────────────── /// Tests that the overflow-checked arithmetic protecting the total-weight diff --git a/docs/CONTRACT_API.md b/docs/CONTRACT_API.md index 6a6d6f0..3a36027 100644 --- a/docs/CONTRACT_API.md +++ b/docs/CONTRACT_API.md @@ -294,6 +294,23 @@ Returns the current voting weight for `owner`. The weight reflects the owner's i --- +## `get_owner_weights` + +```rust +fn get_owner_weights(env: Env) -> Result, ContractError> +``` + +Returns every current owner's address paired with their voting weight, in a single call. The returned list is a `Vec` where each entry contains an `owner` field (the address) and a `weight` field (the owner's individual voting weight). The sum of all returned weights equals the current total-weight counter. This avoids the need for N separate `get_owner_weight` calls when rendering a full governance overview. Read-only; no authorization required. + +| Return field | Type | Description | +|---|---|---| +| `owner` | `Address` | A current owner's address | +| `weight` | `u32` | That owner's individual voting weight | + +**Errors:** `NotInitialized` + +--- + ## `has_approved` ```rust From f2a0a564d6bc4b5a224a2a8a968a84edae45725a Mon Sep 17 00:00:00 2001 From: connelblaze Date: Tue, 28 Jul 2026 07:37:39 +0100 Subject: [PATCH 2/2] change approve/revoke to accumulate the approvers weight --- contracts/accord/src/lib.rs | 18 ++++++ contracts/accord/src/test.rs | 108 +++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/contracts/accord/src/lib.rs b/contracts/accord/src/lib.rs index 8916b52..dbd9bef 100644 --- a/contracts/accord/src/lib.rs +++ b/contracts/accord/src/lib.rs @@ -72,6 +72,7 @@ pub struct Proposal { pub description: String, pub deadline: u64, pub approvals: u32, + pub approval_weight: u32, pub status: ProposalStatus, pub kind: ProposalKind, pub ready_at: u64, @@ -1031,6 +1032,7 @@ impl AccordContract { description, deadline, approvals: 0, + approval_weight: 0, status: ProposalStatus::Pending, kind: ProposalKind::Transfer(transfers.clone()), ready_at: 0, @@ -1120,6 +1122,7 @@ impl AccordContract { description, deadline, approvals: 0, + approval_weight: 0, status: ProposalStatus::Pending, kind: ProposalKind::AddOwner(new_owner, weight), ready_at: 0, @@ -1192,6 +1195,7 @@ impl AccordContract { description, deadline, approvals: 0, + approval_weight: 0, status: ProposalStatus::Pending, kind: ProposalKind::SetSpendingLimit(owner, token, limit), ready_at: 0, @@ -1290,6 +1294,7 @@ impl AccordContract { description, deadline, approvals: 0, + approval_weight: 0, status: ProposalStatus::Pending, kind: ProposalKind::ChangeOwnerWeight(target_owner, new_weight), ready_at: 0, @@ -1401,6 +1406,7 @@ impl AccordContract { description, deadline, approvals: 0, + approval_weight: 0, status: ProposalStatus::Pending, kind: ProposalKind::RemoveOwner(owner_to_remove), ready_at: 0, @@ -1479,6 +1485,7 @@ impl AccordContract { description, deadline, approvals: 0, + approval_weight: 0, status: ProposalStatus::Pending, kind: ProposalKind::ChangeThreshold(new_threshold), ready_at: 0, @@ -1539,6 +1546,11 @@ impl AccordContract { .checked_add(weight) .ok_or(ContractError::ArithmeticError)?; + proposal.approval_weight = proposal + .approval_weight + .checked_add(weight) + .ok_or(ContractError::ArithmeticError)?; + // Record the timestamp when the proposal first crosses the threshold. if proposal.ready_at == 0 && proposal.approvals >= proposal.quorum_weight { proposal.ready_at = env.ledger().timestamp(); @@ -1591,6 +1603,12 @@ impl AccordContract { .approvals .checked_sub(weight) .ok_or(ContractError::ArithmeticError)?; + + proposal.approval_weight = proposal + .approval_weight + .checked_sub(weight) + .ok_or(ContractError::ArithmeticError)?; + proposal.status = derive_status(&env, &proposal); write_proposal(&env, &proposal); diff --git a/contracts/accord/src/test.rs b/contracts/accord/src/test.rs index 3ea9f21..c1b1e34 100644 --- a/contracts/accord/src/test.rs +++ b/contracts/accord/src/test.rs @@ -1219,6 +1219,7 @@ fn approve_returns_arithmetic_error_on_overflow() { description: str(&env, "Overflow approvals"), deadline: DEADLINE, approvals: u32::MAX, + approval_weight: u32::MAX, status: ProposalStatus::Pending, kind: ProposalKind::Transfer(t( &env, @@ -1579,6 +1580,113 @@ fn revoke_rejects_when_not_previously_approved() { ); } +// ─── approval_weight ───────────────────────────────────────────────────── + +/// An owner with weight greater than one must increase approval_weight by +/// that owner's full weight on approve and decrease it by that owner's full +/// weight on revoke, confirming the field tracks cumulative weight independently +/// of the flat approvals counter. +#[test] +fn approval_weight_tracks_weighted_approve_and_revoke() { + let env = Env::default(); + env.mock_all_auths(); + set_timestamp(&env, NOW); + + let owner_a = Address::generate(&env); + let owner_b = Address::generate(&env); + let token_admin = Address::generate(&env); + + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = token::Client::new(&env, &token_id.address()); + let token_sac = token::StellarAssetClient::new(&env, &token_id.address()); + + let contract_id = env.register(AccordContract, ()); + let client = AccordContractClient::new(&env, &contract_id); + + let mut owners = Vec::new(&env); + owners.push_back(owner_a.clone()); + owners.push_back(owner_b.clone()); + + // Weights: Owner A = 4, Owner B = 2. Quorum = 5. + let mut weights = Vec::new(&env); + weights.push_back(4); + weights.push_back(2); + client.initialize(&owners, &weights, &5, &0); + + token_sac.mint(&contract_id, &1_000_000_000_000_i128); + + let id = client.create_proposal( + &owner_a, + &t( + &env, + &Address::generate(&env), + 1_000_000, + &token_client.address, + ), + &str(&env, "Weighted approval_weight"), + &DEADLINE, + &ProposalCategory::Transfer, + ); + + // Initially zero. + let p = client.get_proposal(&id); + assert_eq!(p.approval_weight, 0); + assert_eq!(p.approvals, 0); + + // Owner A (weight 4) approves → approval_weight = 4. + client.approve(&owner_a, &id); + let p = client.get_proposal(&id); + assert_eq!(p.approval_weight, 4); + assert_eq!(p.approvals, 4); + + // Owner A revokes → approval_weight = 0. + client.revoke(&owner_a, &id); + let p = client.get_proposal(&id); + assert_eq!(p.approval_weight, 0); + assert_eq!(p.approvals, 0); +} + +/// Multiple owners with different weights approving in sequence must produce +/// the correct cumulative approval_weight at each step. +#[test] +fn approval_weight_accumulates_correctly_with_multiple_weighted_approvers() { + let (env, client, owner_a, owner_b, owner_c, _, token_client) = + setup_three_owner_weighted([5, 3, 2], 8); + + let id = client.create_proposal( + &owner_a, + &t( + &env, + &Address::generate(&env), + 1_000_000, + &token_client.address, + ), + &str(&env, "Multi-weight approval_weight"), + &DEADLINE, + &ProposalCategory::Transfer, + ); + + // Owner A (weight 5) → approval_weight = 5. + client.approve(&owner_a, &id); + let p = client.get_proposal(&id); + assert_eq!(p.approval_weight, 5); + + // Owner B (weight 3) → approval_weight = 8. + client.approve(&owner_b, &id); + let p = client.get_proposal(&id); + assert_eq!(p.approval_weight, 8); + + // Owner C (weight 2) → approval_weight = 10. + client.approve(&owner_c, &id); + let p = client.get_proposal(&id); + assert_eq!(p.approval_weight, 10); + + // Revoke B (weight 3) → approval_weight = 7. + client.revoke(&owner_b, &id); + let p = client.get_proposal(&id); + assert_eq!(p.approval_weight, 7); +} + // ─── Revoke → Re-approve ────────────────────────────────────────────────────── #[test]