Fixes Issue 25: Implement goal-based and rotational group types - #29
Conversation
📝 WalkthroughWalkthroughThe contract adds rotational and goal-based group configuration, flexible goal-based contributions, savings withdrawals with optional target locking, and group-specific cycle behavior. Tests and initialization calls are updated for the expanded API, and ChangesGoal-based savings
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Member
participant withdraw_savings
participant Contributions
participant TokenContract
participant Events
Member->>withdraw_savings: request withdrawal amount
withdraw_savings->>Contributions: validate and reduce savings
withdraw_savings->>TokenContract: transfer tokens to member
withdraw_savings->>Events: publish withdraw event
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@contracts/Cargo.toml`:
- Around line 10-13: Update the Soroban 27 compatibility path across the Rust
workflow, contracts/deploy.sh, and the contract implementation in
contracts/src/lib.rs: replace the legacy wasm32 cargo build flow with the newer
Soroban CLI/target commands, and migrate env.events().publish usage plus related
tests to the Soroban 27-compatible API. Keep deployment and test behavior
unchanged while ensuring all build, deployment, and contract event paths use the
updated SDK conventions.
In `@contracts/src/lib.rs`:
- Around line 132-155: Update the downstream HasContributedThisCycle validation
to apply only when group_type is Rotational, allowing GoalBased members to
contribute multiple times without requiring reset_cycle(). Preserve the existing
rotational once-per-cycle restriction and use the GroupType value established in
the contribution flow.
- Around line 296-302: Update the lock check around DataKey::TargetAmount so
reaching the target is persisted independently of mutable current_contribution.
Add or reuse a stored target-reached flag, set it when the contribution first
reaches target_amount, and have subsequent withdrawal gating consult that flag
so partial withdrawals do not re-engage the lock.
- Around line 78-83: Validate the configuration in the initialization logic
before persisting values: when lock_until_target is true, require target_amount
to be Some and greater than zero, rejecting otherwise. Preserve the existing
TargetAmount storage behavior for valid configurations and use the contract’s
established error mechanism to abort invalid initialization.
- Around line 320-321: Replace the legacy env.events().publish call in the
contract’s withdrawal flow with a #[contractevent] event struct representing the
withdraw event and publish it via the generated Event.publish(&env) API. Update
all remaining legacy publish calls in contracts/src/lib.rs consistently,
preserving each event’s existing topic values and payloads.
In `@contracts/src/test.rs`:
- Around line 268-274: Remove the stale explanatory comment and the associated
client.reset_cycle() call between the two client.contribute invocations in this
test. Preserve the contribution sequence while relying on the corrected
contribution-cadence behavior from the GoalBased implementation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: be43d2bc-1add-42dd-bef7-7fc84f1e4608
📒 Files selected for processing (3)
contracts/Cargo.tomlcontracts/src/lib.rscontracts/src/test.rs
| soroban-sdk = "27.0.0" | ||
|
|
||
| [dev-dependencies] | ||
| soroban-sdk = { version = "21.0.0", features = ["testutils"] } | ||
| soroban-sdk = { version = "27.0.0", features = ["testutils"] } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
soroban-sdk 27.0.0 migration breaking changes from 21
💡 Result:
Migrating from soroban-sdk v21 to v27 involves significant changes, as you are skipping several major releases (v22 through v26) in addition to the changes in v27 [1][2][3]. Key migration areas include: 1. Protocol Support and Environment: - SDK v27 is adapted for Stellar Protocol 27 [4]. - You must update your environment to build for the wasm32v1-none target using stellar-cli (do not use cargo build directly) [5]. - The dependency on stellar-xdr has been updated, and the curr feature flag has been removed [4]. 2. CAP-71 Authorization Delegation: - SDK v27 introduces support for CAP-71, adding new APIs for delegated authentication [4]. - New functions include Address::delegate_account_auth and Env::get_delegated_signers_for_current_auth_check [4]. 3. Major Breaking Changes Across Versions (v21-v27): - v22: Env::register and Env::register_at replaced previous registration methods; DeployerWithAddress::deploy_v2 replaced deploy; fuzz_catch_panic was deprecated [2]. - v23: contractevent replaced Events::publish; MuxedAddress replaced Address in certain TokenInterface methods; changes were made to how archived persistent entries are tested [2]. - v27 Specific: Functional changes were introduced regarding cfg attributes and export arguments (see the _migrating module in the SDK source for detailed guidance) [1][3]. Recommendations: - Consult the _migrating module within the soroban-sdk documentation or source code for a comprehensive, version-by-version summary [5][6][3]. - Review the GitHub release notes for every major version release between v21 and v27 to identify specific breaking changes relevant to your contract code [1][7]. - Update your test suite, as snapshot formats for protocol v27 have been updated and some diagnostic events are no longer captured in snapshots [4][2].
Citations:
- 1: https://github.com/stellar/rs-soroban-sdk/releases/tag/v27.0.0
- 2: https://docs.rs/crate/soroban-sdk/23.4.0/source/src/_migrating.rs
- 3: stellar/rs-soroban-sdk@v26.1.0...v27.0.0
- 4: Update SDK to p27 and add CAP-71 auth delegation support. stellar/rs-soroban-sdk#1896
- 5: https://docs.rs/crate/soroban-sdk/latest
- 6: https://docs.rs/soroban-sdk/latest/soroban_sdk/
- 7: https://github.com/stellar/rs-soroban-sdk/releases
🏁 Script executed:
git ls-files | sed -n '1,220p'Repository: Kolo-Org/Kolo-smartcontract
Length of output: 961
🏁 Script executed:
fd -H -I -t f 'Cargo.toml|rust-toolchain|*.yml|*.yaml|*.rs' .Repository: Kolo-Org/Kolo-smartcontract
Length of output: 514
🏁 Script executed:
git ls-files | sed -n '1,240p'Repository: Kolo-Org/Kolo-smartcontract
Length of output: 961
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf 'FILES\n'
git ls-files | rg '(^|/)(Cargo.toml|rust-toolchain(\.toml)?|\.github/workflows/.*\.(ya?ml)|.*\.rs)$' | sed -n '1,240p'Repository: Kolo-Org/Kolo-smartcontract
Length of output: 312
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== contracts/Cargo.toml ==\n'
cat -n contracts/Cargo.toml
printf '\n== .github/workflows/rust.yml ==\n'
cat -n .github/workflows/rust.yml
printf '\n== github_actions/workflows/ci.yml ==\n'
cat -n github_actions/workflows/ci.yml
printf '\n== contracts/src/lib.rs ==\n'
cat -n contracts/src/lib.rs
printf '\n== contracts/src/test.rs ==\n'
cat -n contracts/src/test.rs
printf '\n== contracts/deploy.sh ==\n'
cat -n contracts/deploy.shRepository: Kolo-Org/Kolo-smartcontract
Length of output: 43935
Update the Soroban 27 compatibility path. .github/workflows/rust.yml and contracts/deploy.sh still build with cargo build --target wasm32-unknown-unknown, but this SDK bump should move to the newer Soroban CLI/target. contracts/src/lib.rs also still uses env.events().publish, so the contract/tests need a compatibility pass before merge.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@contracts/Cargo.toml` around lines 10 - 13, Update the Soroban 27
compatibility path across the Rust workflow, contracts/deploy.sh, and the
contract implementation in contracts/src/lib.rs: replace the legacy wasm32 cargo
build flow with the newer Soroban CLI/target commands, and migrate
env.events().publish usage plus related tests to the Soroban 27-compatible API.
Keep deployment and test behavior unchanged while ensuring all build,
deployment, and contract event paths use the updated SDK conventions.
| if let Some(target) = target_amount { | ||
| env.storage().instance().set(&DataKey::TargetAmount, &target); | ||
| } | ||
| env.storage() | ||
| .instance() | ||
| .set(&DataKey::LockUntilTarget, &lock_until_target); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Validate target_amount when lock_until_target is enabled.
When lock_until_target is true but target_amount is None, no TargetAmount key is written. In withdraw_savings, the lock check is wrapped in if let Some(target_amount) = ...get(TargetAmount), so a missing key silently bypasses the lock entirely and permits immediate withdrawal — the opposite of the intended "locked until target" guarantee. Reject this inconsistent configuration at init (and ideally require target > 0).
🛡️ Proposed guard
env.storage().instance().set(&DataKey::GroupType, &group_type);
+if lock_until_target && target_amount.is_none() {
+ panic!("lock_until_target requires a target_amount");
+}
if let Some(target) = target_amount {
+ if target <= 0 {
+ panic!("target_amount must be positive");
+ }
env.storage().instance().set(&DataKey::TargetAmount, &target);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if let Some(target) = target_amount { | |
| env.storage().instance().set(&DataKey::TargetAmount, &target); | |
| } | |
| env.storage() | |
| .instance() | |
| .set(&DataKey::LockUntilTarget, &lock_until_target); | |
| if lock_until_target && target_amount.is_none() { | |
| panic!("lock_until_target requires a target_amount"); | |
| } | |
| if let Some(target) = target_amount { | |
| if target <= 0 { | |
| panic!("target_amount must be positive"); | |
| } | |
| env.storage().instance().set(&DataKey::TargetAmount, &target); | |
| } | |
| env.storage() | |
| .instance() | |
| .set(&DataKey::LockUntilTarget, &lock_until_target); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@contracts/src/lib.rs` around lines 78 - 83, Validate the configuration in the
initialization logic before persisting values: when lock_until_target is true,
require target_amount to be Some and greater than zero, rejecting otherwise.
Preserve the existing TargetAmount storage behavior for valid configurations and
use the contract’s established error mechanism to abort invalid initialization.
| let group_type: GroupType = env.storage().instance().get(&DataKey::GroupType).unwrap_or(GroupType::Rotational); | ||
|
|
||
| if group_type == GroupType::Rotational && amount != expected_amount { | ||
| panic!("Must contribute the exact amount"); | ||
| } | ||
|
|
||
| if amount <= 0 { | ||
| panic!("Amount must be positive"); | ||
| } | ||
|
|
||
| let members: Vec<Address> = env.storage().instance().get(&DataKey::Members).unwrap(); | ||
| if !members.contains(&member) { | ||
| panic!("Not a member"); | ||
| } | ||
|
|
||
| // Freeze the member count at the start of a cycle on the first contribution | ||
| if !env.storage().instance().has(&DataKey::CycleMemberCount) { | ||
| let count = members.len() as i128; | ||
| env.storage() | ||
| .instance() | ||
| .set(&DataKey::CycleMemberCount, &count); | ||
| if group_type == GroupType::Rotational { | ||
| if !env.storage().instance().has(&DataKey::CycleMemberCount) { | ||
| let count = members.len() as i128; | ||
| env.storage() | ||
| .instance() | ||
| .set(&DataKey::CycleMemberCount, &count); | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
GoalBased contributions are not actually "flexible" in cadence.
The exact-amount and cycle-freeze branches are correctly gated to Rotational, but the downstream HasContributedThisCycle gate (Lines 157‑164) is still applied to GoalBased groups. A GoalBased member can therefore contribute only once until an admin calls reset_cycle(), which contradicts the goal-based "flexible contributions" objective and is confirmed by the workaround in test_goalbased_flexible_contributions. Consider skipping the once-per-cycle gate for GoalBased (the cycle concept only applies to rotational payouts).
🧰 Tools
🪛 Clippy (1.96.0)
[warning] 148-148: this if statement can be collapsed
(warning)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@contracts/src/lib.rs` around lines 132 - 155, Update the downstream
HasContributedThisCycle validation to apply only when group_type is Rotational,
allowing GoalBased members to contribute multiple times without requiring
reset_cycle(). Preserve the existing rotational once-per-cycle restriction and
use the GroupType value established in the contribution flow.
| if lock_until_target { | ||
| if let Some(target_amount) = env.storage().instance().get::<_, i128>(&DataKey::TargetAmount) { | ||
| if current_contribution < target_amount { | ||
| panic!("Target amount not reached yet"); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Lock re-engages after a partial withdrawal.
The gate compares the live current_contribution against target_amount. Once a member reaches the target and withdraws part of it, the remaining balance drops below target_amount, so any subsequent withdrawal panics with "Target amount not reached yet" — locking funds the member already unlocked. Track a "target reached" flag (persisted once reached), or gate on cumulative contributions rather than the mutable balance.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@contracts/src/lib.rs` around lines 296 - 302, Update the lock check around
DataKey::TargetAmount so reaching the target is persisted independently of
mutable current_contribution. Add or reuse a stored target-reached flag, set it
when the contribution first reaches target_amount, and have subsequent
withdrawal gating consult that flag so partial withdrawals do not re-engage the
lock.
| env.events() | ||
| .publish((symbol_short!("withdraw"), member), amount); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
soroban-sdk 27 events publish deprecated contractevent migration
💡 Result:
In the soroban-sdk, the Events::publish method has been deprecated in favor of the #[contractevent] macro [1]. This migration was originally introduced in version 23 and has become standard practice [2][3]. To migrate from Events::publish to #[contractevent]: 1. Define a struct for your event and annotate it with #[contractevent]. By default, this uses a single static topic (the event name in snake_case) and treats fields not marked as #[topic] as data collected into a Map [2][4]. 2. Use the #[topic] attribute on fields that should be used as indexed topics [2][4]. 3. If necessary, customize the event's static topics using the topics attribute (e.g., #[contractevent(topics = [...])]) [2][4]. 4. Publish the event by calling .publish(&env) on an instance of your struct [4]. The #[contractevent] macro is preferred because it provides type safety and includes the event definition in the contract interface specification, which allows tooling, SDKs, and generated clients to better understand and interact with published events [2][5]. In newer versions of the SDK (including the v27 series), legacy event handling mechanisms have been systematically removed [6]. If you are working on a codebase transitioning to SDK 27, you should replace all env.events().publish(...) calls with the #[contractevent] macro [3].
Citations:
- 1: https://docs.rs/soroban-sdk/latest/soroban_sdk/events/struct.Events.html
- 2: https://docs.rs/soroban-sdk/latest/soroban%5Fsdk/%5Fmigrating/v23%5Fcontractevent/index.html
- 3: refactor(events): migrate Events::publish to #[contractevent] Crossmint/stellar-smart-account#133
- 4: https://docs.rs/soroban-sdk/latest/soroban_sdk/_migrating/v23_contractevent/index.html
- 5: https://docs.rs/soroban-sdk/latest/src/soroban_sdk/_migrating/v23_contractevent.rs.html
- 6: https://github.com/stellar/rs-soroban-sdk/releases
Migrate the remaining legacy event publishes
env.events().publish(...) is deprecated in soroban-sdk 27. Replace the remaining calls in contracts/src/lib.rs with #[contractevent] structs and Event.publish(&env) to avoid warning-as-error failures.
🧰 Tools
🪛 Clippy (1.96.0)
[warning] 321-321: use of deprecated method soroban_sdk::events::Events::publish
(warning)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@contracts/src/lib.rs` around lines 320 - 321, Replace the legacy
env.events().publish call in the contract’s withdrawal flow with a
#[contractevent] event struct representing the withdraw event and publish it via
the generated Event.publish(&env) API. Update all remaining legacy publish calls
in contracts/src/lib.rs consistently, preserving each event’s existing topic
values and payloads.
Source: Linters/SAST tools
| // Can contribute varying amounts | ||
| client.contribute(&member, &500); | ||
|
|
||
| // Wait, the test above calls contribute twice in a row, but HasContributedThisCycle is still active. | ||
| // So we need to call reset_cycle() or it will panic. | ||
| client.reset_cycle(); | ||
| client.contribute(&member, &1500); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Remove the stale/confusing comment.
The comment "the test above calls contribute twice in a row..." is misleading — there's only one prior contribute, and the note reads like a leftover debugging remark. It also surfaces the underlying design smell (needing reset_cycle() between GoalBased contributions) flagged in contracts/src/lib.rs. Once the contribution-cadence issue is addressed, this reset_cycle() call and comment should be dropped.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@contracts/src/test.rs` around lines 268 - 274, Remove the stale explanatory
comment and the associated client.reset_cycle() call between the two
client.contribute invocations in this test. Preserve the contribution sequence
while relying on the corrected contribution-cadence behavior from the GoalBased
implementation.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/rust.yml:
- Line 23: Update all WASM target references to remain consistent with the
target used by the workflow’s rustup target installation: change the references
in contracts/deploy.sh, README.md, and github_actions/workflows/ci.yml from
wasm32-unknown-unknown to wasm32v1-none, including any related artifact paths.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: afcbae3a-62bd-48d6-a2c8-30af790484c4
📒 Files selected for processing (1)
.github/workflows/rust.yml
| rustup toolchain install stable --profile minimal | ||
| rustup default stable | ||
| rustup target add wasm32-unknown-unknown | ||
| rustup target add wasm32v1-none |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
rg -n 'wasm32(-unknown-unknown|v1-none)|wasm32v1-none' \
--glob '*.yml' --glob '*.yaml' --glob '*.sh' --glob 'README*' --glob 'Cargo.toml'Repository: Kolo-Org/Kolo-smartcontract
Length of output: 165
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf 'Files:\n'
git ls-files | rg '(^|/)(rust\.yml|ci\.yml|deploy\.sh|README\.md|Cargo\.toml)$|github_actions/workflows'
printf '\nTarget references:\n'
rg -n 'wasm32(-unknown-unknown|v1-none)|wasm32v1-none' \
.github github_actions contracts README.md Cargo.toml \
--glob '*.yml' --glob '*.yaml' --glob '*.sh' --glob 'README*' --glob 'Cargo.toml' || trueRepository: Kolo-Org/Kolo-smartcontract
Length of output: 2549
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf 'Workflow files:\n'
git ls-files '.github/workflows/*' 'github_actions/workflows/*' | sort
printf '\nPotential deployment files:\n'
git ls-files | rg 'deploy\.sh|README\.md|Cargo\.toml|ci\.yml$' | sort
printf '\nRust workflow excerpt:\n'
nl -ba .github/workflows/rust.yml | sed -n '1,120p'Repository: Kolo-Org/Kolo-smartcontract
Length of output: 724
Keep the WASM target consistent across the repo. This workflow now uses wasm32v1-none, but contracts/deploy.sh, README.md, and github_actions/workflows/ci.yml still reference wasm32-unknown-unknown, so CI/docs/deploy will point at different artifact paths. Update the other target references together with this change.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/rust.yml at line 23, Update all WASM target references to
remain consistent with the target used by the workflow’s rustup target
installation: change the references in contracts/deploy.sh, README.md, and
github_actions/workflows/ci.yml from wasm32-unknown-unknown to wasm32v1-none,
including any related artifact paths.
|
Thank you for your contribution @Ultra-Tech-code .great job |
Description
Closes #25
This PR implements the Goal-Based and Rotational group types for the Kolo smart contract, fulfilling the requirements for supporting goal-oriented savings alongside the traditional Ajo/Esusu rotational payout model.
Changes Made
GroupTypeEnum Integration: AddedGroupTypewithRotationalandGoalBasedvariants, along withTargetAmountandLockUntilTargetinstance states to the contract data keys.initializefunction now accepts the newly required parameters (group_type,target_amount,lock_until_target) to safely configure new savings pools.contributelogic soGoalBasedgroups allow members to deposit flexible amounts without triggering the strictcontribution_amountvalidation reserved for Rotational groups.withdraw_savingsfunction exclusively forGoalBasedgroups. This correctly decrements a user's accumulated savings and enforces an optionallock_until_targetcheck to restrict withdrawals until their goal is met.payoutandreset_cyclestrictly enforce the Ajo/Esusu rotational logic and cannot be improperly triggered byGoalBasedgroups.soroban-sdkto27.0.0for latest framework compatibility.Testing
Comprehensive unit testing has been implemented, securing >85% test coverage for the new feature flows:
test_goalbased_flexible_contributions: Asserts varying amounts can be successfully deposited.test_goalbased_partial_withdrawal: Asserts members can withdraw their own accumulated contributions at any time.test_goalbased_over_withdrawal: Verifies panics on overdrafts.test_goalbased_locked_until_target: Asserts thatlock_until_targetaccurately locks withdrawals untilTargetAmountis reached.GroupType::Rotationalto guarantee legacy logic behavior is completely preserved.Summary by CodeRabbit
withdraw_savingswith optional target locking.wasm32v1-none.Cargo.lock.