Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
14 changes: 14 additions & 0 deletions contracts/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions contracts/contracts/group_treasury/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "group_treasury"
version = "0.1.0"
edition = "2021"
publish = false

[lib]
crate-type = ["lib", "cdylib"]
doctest = false

[dependencies]
soroban-sdk = { workspace = true }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
101 changes: 101 additions & 0 deletions contracts/contracts/group_treasury/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#![no_std]

mod storage;
mod token_interface;
mod test;

use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Vec};
use storage::{DataKey, DepositEvent, MemberList, WithdrawEvent};
use token_interface::TokenClient;

#[contract]
pub struct GroupTreasuryContract;

#[contractimpl]
impl GroupTreasuryContract {
pub fn initialize(env: Env, admin: Address, token_contract: Address, members: Vec<Address>) {
if env.storage().instance().has(&DataKey::Admin) {
panic!("already initialized");
}
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage().instance().set(&DataKey::TokenContract, &token_contract);
env.storage().instance().set(&DataKey::Members, &members);
}

/// Admin-only: authorise the proposals contract to call withdraw.
pub fn set_proposals_contract(env: Env, proposals_contract: Address) {
let admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.expect("not initialized");
admin.require_auth();
env.storage()
.instance()
.set(&DataKey::ProposalsContract, &proposals_contract);
}

/// Transfer tokens from `from` into the treasury.
pub fn deposit(env: Env, from: Address, amount: i128) {
if amount <= 0 {
panic!("amount must be positive");
}
from.require_auth();
let token_id: Address = env
.storage()
.instance()
.get(&DataKey::TokenContract)
.expect("not initialized");
TokenClient::new(&env, &token_id).transfer(&from, &env.current_contract_address(), &amount);
env.events()
.publish((Symbol::new(&env, "deposit"),), DepositEvent { from, amount });
}

/// Transfer tokens out of the treasury to `to`.
/// Only the authorised proposals contract may call this.
pub fn withdraw(env: Env, to: Address, amount: i128) {
if amount <= 0 {
panic!("amount must be positive");
}
let proposals: Address = env
.storage()
.instance()
.get(&DataKey::ProposalsContract)
.expect("proposals contract not set");
// Satisfied automatically when the proposals contract makes a cross-contract call
proposals.require_auth();
let token_id: Address = env
.storage()
.instance()
.get(&DataKey::TokenContract)
.expect("not initialized");
TokenClient::new(&env, &token_id).transfer(&env.current_contract_address(), &to, &amount);
env.events()
.publish((Symbol::new(&env, "withdraw"),), WithdrawEvent { to, amount });
}

pub fn balance(env: Env) -> i128 {
let token_id: Address = env
.storage()
.instance()
.get(&DataKey::TokenContract)
.expect("not initialized");
TokenClient::new(&env, &token_id).balance(&env.current_contract_address())
}

pub fn is_member(env: Env, addr: Address) -> bool {
let members: MemberList = env
.storage()
.instance()
.get(&DataKey::Members)
.unwrap_or_else(|| Vec::new(&env));
members.contains(&addr)
}

pub fn get_members(env: Env) -> MemberList {
env.storage()
.instance()
.get(&DataKey::Members)
.unwrap_or_else(|| Vec::new(&env))
}
}
23 changes: 23 additions & 0 deletions contracts/contracts/group_treasury/src/storage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
use soroban_sdk::{contracttype, Address, Vec};

#[contracttype]
pub enum DataKey {
Admin,
TokenContract,
ProposalsContract,
Members,
}

#[contracttype]
pub struct DepositEvent {
pub from: Address,
pub amount: i128,
}

#[contracttype]
pub struct WithdrawEvent {
pub to: Address,
pub amount: i128,
}

pub type MemberList = Vec<Address>;
156 changes: 156 additions & 0 deletions contracts/contracts/group_treasury/src/test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#![cfg(test)]

use super::*;
use soroban_sdk::{testutils::Address as _, Address, Env, Vec};

mod mock_token {
use soroban_sdk::{contract, contractimpl, contracttype, Address, Env};

#[contracttype]
pub enum Key {
Balance(Address),
}

#[contract]
pub struct MockToken;

#[contractimpl]
impl MockToken {
pub fn mint(env: Env, to: Address, amount: i128) {
let key = Key::Balance(to);
let current: i128 = env.storage().persistent().get(&key).unwrap_or(0);
env.storage().persistent().set(&key, &(current + amount));
}

pub fn transfer(env: Env, from: Address, to: Address, amount: i128) {
from.require_auth();
let from_key = Key::Balance(from.clone());
let to_key = Key::Balance(to.clone());
let from_bal: i128 = env.storage().persistent().get(&from_key).unwrap_or(0);
assert!(from_bal >= amount, "insufficient balance");
env.storage().persistent().set(&from_key, &(from_bal - amount));
let to_bal: i128 = env.storage().persistent().get(&to_key).unwrap_or(0);
env.storage().persistent().set(&to_key, &(to_bal + amount));
}

pub fn balance(env: Env, id: Address) -> i128 {
env.storage()
.persistent()
.get(&Key::Balance(id))
.unwrap_or(0)
}
}
}

use mock_token::MockTokenClient;

fn setup(env: &Env) -> (Address, Address, Address, Address, Address) {
let admin = Address::generate(env);
let member1 = Address::generate(env);
let member2 = Address::generate(env);

let token_id = env.register(mock_token::MockToken, ());
let token = MockTokenClient::new(env, &token_id);
token.mint(&member1, &1_000_000);
token.mint(&member2, &500_000);

let mut members = Vec::new(env);
members.push_back(member1.clone());
members.push_back(member2.clone());

let treasury_id = env.register(GroupTreasuryContract, ());
let treasury = GroupTreasuryContractClient::new(env, &treasury_id);
treasury.initialize(&admin, &token_id, &members);

(treasury_id, token_id, admin, member1, member2)
}

#[test]
fn test_initialize() {
let env = Env::default();
let (treasury_id, _token_id, _admin, member1, member2) = setup(&env);
let treasury = GroupTreasuryContractClient::new(&env, &treasury_id);
assert!(treasury.is_member(&member1));
assert!(treasury.is_member(&member2));
assert!(!treasury.is_member(&Address::generate(&env)));
}

#[test]
#[should_panic(expected = "already initialized")]
fn test_double_initialize_panics() {
let env = Env::default();
let (treasury_id, token_id, admin, member1, _member2) = setup(&env);
let treasury = GroupTreasuryContractClient::new(&env, &treasury_id);
let mut members = Vec::new(&env);
members.push_back(member1);
treasury.initialize(&admin, &token_id, &members);
}

#[test]
fn test_deposit_and_balance() {
let env = Env::default();
env.mock_all_auths();
let (treasury_id, token_id, _admin, member1, _member2) = setup(&env);
let treasury = GroupTreasuryContractClient::new(&env, &treasury_id);
let token = MockTokenClient::new(&env, &token_id);

assert_eq!(treasury.balance(), 0);
treasury.deposit(&member1, &300_000);
assert_eq!(treasury.balance(), 300_000);
assert_eq!(token.balance(&member1), 700_000);
}

#[test]
#[should_panic(expected = "amount must be positive")]
fn test_deposit_zero_panics() {
let env = Env::default();
env.mock_all_auths();
let (treasury_id, _token_id, _admin, member1, _member2) = setup(&env);
let treasury = GroupTreasuryContractClient::new(&env, &treasury_id);
treasury.deposit(&member1, &0);
}

#[test]
fn test_withdraw_by_proposals_contract() {
let env = Env::default();
env.mock_all_auths();
let (treasury_id, token_id, admin, member1, _member2) = setup(&env);
let treasury = GroupTreasuryContractClient::new(&env, &treasury_id);
let token = MockTokenClient::new(&env, &token_id);

treasury.deposit(&member1, &500_000);

let proposals_contract = Address::generate(&env);
treasury.set_proposals_contract(&proposals_contract);

let recipient = Address::generate(&env);
treasury.withdraw(&recipient, &200_000);

assert_eq!(treasury.balance(), 300_000);
assert_eq!(token.balance(&recipient), 200_000);
let _ = admin;
}

#[test]
#[should_panic(expected = "proposals contract not set")]
fn test_withdraw_without_proposals_contract_panics() {
let env = Env::default();
env.mock_all_auths();
let (treasury_id, _token_id, _admin, member1, _member2) = setup(&env);
let treasury = GroupTreasuryContractClient::new(&env, &treasury_id);
treasury.deposit(&member1, &100_000);

let recipient = Address::generate(&env);
treasury.withdraw(&recipient, &50_000);
}

#[test]
fn test_get_members() {
let env = Env::default();
let (treasury_id, _token_id, _admin, member1, member2) = setup(&env);
let treasury = GroupTreasuryContractClient::new(&env, &treasury_id);
let members = treasury.get_members();
assert_eq!(members.len(), 2);
assert!(members.contains(&member1));
assert!(members.contains(&member2));
}
7 changes: 7 additions & 0 deletions contracts/contracts/group_treasury/src/token_interface.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
use soroban_sdk::{contractclient, Address, Env};

#[contractclient(name = "TokenClient")]
pub trait TokenInterface {
fn transfer(env: Env, from: Address, to: Address, amount: i128);
fn balance(env: Env, id: Address) -> i128;
}
15 changes: 15 additions & 0 deletions contracts/contracts/proposals/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "proposals"
version = "0.1.0"
edition = "2021"
publish = false

[lib]
crate-type = ["lib", "cdylib"]
doctest = false

[dependencies]
soroban-sdk = { workspace = true }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
Loading