Repository: public-awesome/launchpad
Pull request #493 (2023-01-14 06:14:56)

[SG-204] Add admin for fund distribution

linear[bot]: <details>
<summary><a href="https://linear.app/stargaze/issue/SG-204/add-an-executor-for-automating-fund-distribution">SG-204 Add an executor for automating fund distribution</a></summary>
<p>

* If an admin is set, only the admin can call distribute 
* Admin can be updated by admin 
* If no admin is provided, then any group member can call distribute 
* No one outside of the group can call distribute but this has the issue where if the admin key is lost, the contract is essentially locked... i guess could add a sudo withdraw as a failsafe?
* Sudo can update admin
</p>
</details>
codecov[bot]: # [Codecov](https://codecov.io/gh/public-awesome/launchpad/pull/493?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=public-awesome) Report
Base: **76.20**% // Head: **76.18**% // Decreases project coverage by **`-0.01%`** :warning:
> Coverage data is based on head [(`895d824`)](https://codecov.io/gh/public-awesome/launchpad/pull/493?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=public-awesome) compared to base [(`1e9c1fb`)](https://codecov.io/gh/public-awesome/launchpad/commit/1e9c1fbaa91584b2ff11b739c14764224208eaaf?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=public-awesome).
> Patch coverage: 76.47% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@                                   Coverage Diff                                    @@
##           shanev/sg-203-allow-creating-splits-with-an-existing     #493      +/-   ##
========================================================================================
- Coverage                                                 76.20%   76.18%   -0.02%     
========================================================================================
  Files                                                        54       54              
  Lines                                                      2097     2108      +11     
========================================================================================
+ Hits                                                       1598     1606       +8     
- Misses                                                      499      502       +3     
```


| [Impacted Files](https://codecov.io/gh/public-awesome/launchpad/pull/493?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=public-awesome) | Coverage Δ | |
|---|---|---|
| [contracts/splits/src/contract.rs](https://codecov.io/gh/public-awesome/launchpad/pull/493?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=public-awesome#diff-Y29udHJhY3RzL3NwbGl0cy9zcmMvY29udHJhY3QucnM=) | `66.27% <76.47%> (+0.94%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=public-awesome). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=public-awesome)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/public-awesome/launchpad/pull/493?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=public-awesome).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=public-awesome).


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 50cf4725..315b4af2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2191,6 +2191,7 @@ version = "0.21.8"
 dependencies = [
  "cosmwasm-schema",
  "cosmwasm-std",
+ "cw-controllers",
  "cw-multi-test",
  "cw-storage-plus",
  "cw-utils",
diff --git a/Cargo.toml b/Cargo.toml
index c87410cd..9d1494db 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,7 @@ base-factory        = { version = "0.21.8", path = "contracts/base-factory" }
 base-minter         = { version = "0.21.8", path = "contracts/base-minter" }
 cosmwasm-schema     = "1.1.5"
 cosmwasm-std        = "1.1.5"
+cw-controllers      = "0.16.0"
 cw2                 = "0.16.0"
 cw4                 = "0.16.0"
 cw4-group           = "0.16.0"
diff --git a/contracts/splits/Cargo.toml b/contracts/splits/Cargo.toml
index c855e916..2dfd4c33 100644
--- a/contracts/splits/Cargo.toml
+++ b/contracts/splits/Cargo.toml
@@ -28,6 +28,7 @@ library = []
 [dependencies]
 cosmwasm-schema = { workspace = true }
 cosmwasm-std    = { workspace = true }
+cw-controllers  = { workspace = true }
 cw2             = { workspace = true }
 cw4             = { workspace = true }
 cw-storage-plus = { workspace = true }
diff --git a/contracts/splits/schema/sg-splits.json b/contracts/splits/schema/sg-splits.json
index 373b348f..b092d2c4 100644
--- a/contracts/splits/schema/sg-splits.json
+++ b/contracts/splits/schema/sg-splits.json
@@ -10,6 +10,12 @@
       "group"
     ],
     "properties": {
+      "admin": {
+        "type": [
+          "string",
+          "null"
+        ]
+      },
       "group": {
         "$ref": "#/definitions/Group"
       }
@@ -124,6 +130,27 @@
     "$schema": "http://json-schema.org/draft-07/schema#",
     "title": "ExecuteMsg",
     "oneOf": [
+      {
+        "type": "object",
+        "required": [
+          "update_admin"
+        ],
+        "properties": {
+          "update_admin": {
+            "type": "object",
+            "properties": {
+              "admin": {
+                "type": [
+                  "string",
+                  "null"
+                ]
+              }
+            },
+            "additionalProperties": false
+          }
+        },
+        "additionalProperties": false
+      },
       {
         "type": "object",
         "required": [
@@ -143,6 +170,19 @@
     "$schema": "http://json-schema.org/draft-07/schema#",
     "title": "QueryMsg",
     "oneOf": [
+      {
+        "type": "object",
+        "required": [
+          "admin"
+        ],
+        "properties": {
+          "admin": {
+            "type": "object",
+            "additionalProperties": false
+          }
+        },
+        "additionalProperties": false
+      },
       {
         "type": "object",
         "required": [
@@ -211,6 +251,21 @@
   "migrate": null,
   "sudo": null,
   "responses": {
+    "admin": {
+      "$schema": "http://json-schema.org/draft-07/schema#",
+      "title": "AdminResponse",
+      "description": "Returned from Admin.query_admin()",
+      "type": "object",
+      "properties": {
+        "admin": {
+          "type": [
+            "string",
+            "null"
+          ]
+        }
+      },
+      "additionalProperties": false
+    },
     "group": {
       "$schema": "http://json-schema.org/draft-07/schema#",
       "title": "Addr",
diff --git a/contracts/splits/src/contract.rs b/contracts/splits/src/contract.rs
index 6a3d5676..0ee5f78b 100644
--- a/contracts/splits/src/contract.rs
+++ b/contracts/splits/src/contract.rs
@@ -6,12 +6,12 @@ use cosmwasm_std::{
 };
 use cw2::set_contract_version;
 use cw4::{Cw4Contract, Member, MemberListResponse, MemberResponse};
-use cw_utils::parse_reply_instantiate_data;
+use cw_utils::{maybe_addr, parse_reply_instantiate_data};
 use sg_std::NATIVE_DENOM;
 
 use crate::error::ContractError;
 use crate::msg::{ExecuteMsg, Group, InstantiateMsg, QueryMsg};
-use crate::state::GROUP;
+use crate::state::{ADMIN, GROUP};
 
 // version info for migration info
 pub const CONTRACT_NAME: &str = "crates.io:sg-splits";
@@ -21,7 +21,7 @@ const INIT_GROUP_REPLY_ID: u64 = 1;
 
 #[cfg_attr(not(feature = "library"), entry_point)]
 pub fn instantiate(
-    deps: DepsMut,
+    mut deps: DepsMut,
     env: Env,
     _info: MessageInfo,
     msg: InstantiateMsg,
@@ -30,6 +30,9 @@ pub fn instantiate(
 
     let self_addr = env.contract.address;
 
+    let admin_addr = maybe_addr(deps.api, msg.admin)?;
+    ADMIN.set(deps.branch(), admin_addr)?;
+
     match msg.group {
         Group::Cw4Instantiate(init) => Ok(Response::default().add_submessage(
             SubMsg::reply_on_success(init.into_wasm_msg(self_addr), INIT_GROUP_REPLY_ID),
@@ -56,27 +59,28 @@ pub fn execute(
     info: MessageInfo,
     msg: ExecuteMsg,
 ) -> Result<Response, ContractError> {
+    let api = deps.api;
+
     match msg {
-        ExecuteMsg::Distribute {} => execute_distribute(deps, env, info),
+        ExecuteMsg::UpdateAdmin { admin } => {
+            Ok(ADMIN.execute_update_admin(deps, info, maybe_addr(api, admin)?)?)
+        }
+        ExecuteMsg::Distribute {} => execute_distribute(deps.as_ref(), env, info),
     }
 }
 
 pub fn execute_distribute(
-    deps: DepsMut,
+    deps: Deps,
     env: Env,
     info: MessageInfo,
 ) -> Result<Response, ContractError> {
-    let group = GROUP.load(deps.storage)?;
-
-    // only a member can distribute funds
-    let weight = group
-        .is_member(&deps.querier, &info.sender, None)?
-        .ok_or(ContractError::Unauthorized {})?;
-    if weight == 0 {
-        return Err(ContractError::InvalidWeight { weight });
+    if !can_distribute(deps, info)? {
+        return Err(ContractError::Unauthorized {});
     }
 
-    let total_weight = checked_total_weight(&group, deps.as_ref())?;
+    let group = GROUP.load(deps.storage)?;
+
+    let total_weight = checked_total_weight(&group, deps)?;
 
     let funds = deps
         .querier
@@ -112,9 +116,21 @@ fn checked_total_weight(group: &Cw4Contract, deps: Deps) -> Result<u64, Contract
     Ok(weight)
 }
 
+/// Checks if the sender is an admin or a member of a group.
+fn can_distribute(deps: Deps, info: MessageInfo) -> StdResult<bool> {
+    match ADMIN.get(deps)? {
+        Some(admin) => Ok(admin == info.sender),
+        None => Ok(GROUP
+            .load(deps.storage)?
+            .is_member(&deps.querier, &info.sender, None)?
+            .is_some()),
+    }
+}
+
 #[cfg_attr(not(feature = "library"), entry_point)]
 pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
     match msg {
+        QueryMsg::Admin {} => to_binary(&ADMIN.query_admin(deps)?),
         QueryMsg::Group {} => to_binary(&query_group(deps)?),
         QueryMsg::ListMembers { start_after, limit } => {
             to_binary(&list_members(deps, start_after, limit)?)
diff --git a/contracts/splits/src/error.rs b/contracts/splits/src/error.rs
index 1d988da3..b0bae2db 100644
--- a/contracts/splits/src/error.rs
+++ b/contracts/splits/src/error.rs
@@ -1,4 +1,5 @@
 use cosmwasm_std::StdError;
+use cw_controllers::AdminError;
 use thiserror::Error;
 
 #[derive(Error, Debug, PartialEq)]
@@ -6,6 +7,9 @@ pub enum ContractError {
     #[error("{0}")]
     Std(#[from] StdError),
 
+    #[error("{0}")]
+    Admin(#[from] AdminError),
+
     #[error("Unauthorized")]
     Unauthorized {},
 
diff --git a/contracts/splits/src/msg.rs b/contracts/splits/src/msg.rs
index 30631298..62a4ad1f 100644
--- a/contracts/splits/src/msg.rs
+++ b/contracts/splits/src/msg.rs
@@ -10,17 +10,22 @@ pub enum Group {
 
 #[cw_serde]
 pub struct InstantiateMsg {
+    pub admin: Option<String>,
     pub group: Group,
 }
 
 #[cw_serde]
 pub enum ExecuteMsg {
+    UpdateAdmin { admin: Option<String> },
     Distribute {},
 }
 
 #[cw_serde]
 #[derive(QueryResponses)]
 pub enum QueryMsg {
+    #[returns(cw_controllers::AdminResponse)]
+    Admin {},
+
     #[returns(Addr)]
     Group {},
 
diff --git a/contracts/splits/src/state.rs b/contracts/splits/src/state.rs
index 82f59859..8fb0f875 100644
--- a/contracts/splits/src/state.rs
+++ b/contracts/splits/src/state.rs
@@ -1,5 +1,8 @@
 use cw4::Cw4Contract;
 
+use cw_controllers::Admin;
 use cw_storage_plus::Item;
 
 pub const GROUP: Item<Cw4Contract> = Item::new("group");
+
+pub const ADMIN: Admin = Admin::new("admin");
diff --git a/ts/src/Splits.client.ts b/ts/src/Splits.client.ts
index 84ef265f..baefc164 100644
--- a/ts/src/Splits.client.ts
+++ b/ts/src/Splits.client.ts
@@ -6,9 +6,10 @@
 
 import { CosmWasmClient, SigningCosmWasmClient, ExecuteResult } from "@cosmjs/cosmwasm-stargate";
 import { Coin, StdFee } from "@cosmjs/amino";
-import { Group, Admin, Binary, InstantiateMsg, ContractInstantiateMsg, ExecuteMsg, QueryMsg, Addr, MemberListResponse, Member, MemberResponse } from "./Splits.types";
+import { Group, Admin, Binary, InstantiateMsg, ContractInstantiateMsg, ExecuteMsg, QueryMsg, AdminResponse, Addr, MemberListResponse, Member, MemberResponse } from "./Splits.types";
 export interface SplitsReadOnlyInterface {
   contractAddress: string;
+  admin: () => Promise<AdminResponse>;
   group: () => Promise<Addr>;
   member: ({
     address
@@ -30,11 +31,17 @@ export class SplitsQueryClient implements SplitsReadOnlyInterface {
   constructor(client: CosmWasmClient, contractAddress: string) {
     this.client = client;
     this.contractAddress = contractAddress;
+    this.admin = this.admin.bind(this);
     this.group = this.group.bind(this);
     this.member = this.member.bind(this);
     this.listMembers = this.listMembers.bind(this);
   }
 
+  admin = async (): Promise<AdminResponse> => {
+    return this.client.queryContractSmart(this.contractAddress, {
+      admin: {}
+    });
+  };
   group = async (): Promise<Addr> => {
     return this.client.queryContractSmart(this.contractAddress, {
       group: {}
@@ -69,6 +76,11 @@ export class SplitsQueryClient implements SplitsReadOnlyInterface {
 export interface SplitsInterface extends SplitsReadOnlyInterface {
   contractAddress: string;
   sender: string;
+  updateAdmin: ({
+    admin
+  }: {
+    admin?: string;
+  }, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]) => Promise<ExecuteResult>;
   distribute: (fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]) => Promise<ExecuteResult>;
 }
 export class SplitsClient extends SplitsQueryClient implements SplitsInterface {
@@ -81,9 +93,21 @@ export class SplitsClient extends SplitsQueryClient implements SplitsInterface {
     this.client = client;
     this.sender = sender;
     this.contractAddress = contractAddress;
+    this.updateAdmin = this.updateAdmin.bind(this);
     this.distribute = this.distribute.bind(this);
   }
 
+  updateAdmin = async ({
+    admin
+  }: {
+    admin?: string;
+  }, fee: number | StdFee | "auto" = "auto", memo?: string, funds?: Coin[]): Promise<ExecuteResult> => {
+    return await this.client.execute(this.sender, this.contractAddress, {
+      update_admin: {
+        admin
+      }
+    }, fee, memo, funds);
+  };
   distribute = async (fee: number | StdFee | "auto" = "auto", memo?: string, funds?: Coin[]): Promise<ExecuteResult> => {
     return await this.client.execute(this.sender, this.contractAddress, {
       distribute: {}
diff --git a/ts/src/Splits.message-composer.ts b/ts/src/Splits.message-composer.ts
index be8e53bc..9cfd5aee 100644
--- a/ts/src/Splits.message-composer.ts
+++ b/ts/src/Splits.message-composer.ts
@@ -8,10 +8,15 @@ import { Coin } from "@cosmjs/amino";
 import { MsgExecuteContractEncodeObject } from "cosmwasm";
 import { MsgExecuteContract } from "cosmjs-types/cosmwasm/wasm/v1/tx";
 import { toUtf8 } from "@cosmjs/encoding";
-import { Group, Admin, Binary, InstantiateMsg, ContractInstantiateMsg, ExecuteMsg, QueryMsg, Addr, MemberListResponse, Member, MemberResponse } from "./Splits.types";
+import { Group, Admin, Binary, InstantiateMsg, ContractInstantiateMsg, ExecuteMsg, QueryMsg, AdminResponse, Addr, MemberListResponse, Member, MemberResponse } from "./Splits.types";
 export interface SplitsMessage {
   contractAddress: string;
   sender: string;
+  updateAdmin: ({
+    admin
+  }: {
+    admin?: string;
+  }, funds?: Coin[]) => MsgExecuteContractEncodeObject;
   distribute: (funds?: Coin[]) => MsgExecuteContractEncodeObject;
 }
 export class SplitsMessageComposer implements SplitsMessage {
@@ -21,9 +26,29 @@ export class SplitsMessageComposer implements SplitsMessage {
   constructor(sender: string, contractAddress: string) {
     this.sender = sender;
     this.contractAddress = contractAddress;
+    this.updateAdmin = this.updateAdmin.bind(this);
     this.distribute = this.distribute.bind(this);
   }
 
+  updateAdmin = ({
+    admin
+  }: {
+    admin?: string;
+  }, funds?: Coin[]): MsgExecuteContractEncodeObject => {
+    return {
+      typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract",
+      value: MsgExecuteContract.fromPartial({
+        sender: this.sender,
+        contract: this.contractAddress,
+        msg: toUtf8(JSON.stringify({
+          update_admin: {
+            admin
+          }
+        })),
+        funds
+      })
+    };
+  };
   distribute = (funds?: Coin[]): MsgExecuteContractEncodeObject => {
     return {
       typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract",
diff --git a/ts/src/Splits.types.ts b/ts/src/Splits.types.ts
index 22ee4ba8..47a6abd1 100644
--- a/ts/src/Splits.types.ts
+++ b/ts/src/Splits.types.ts
@@ -18,6 +18,7 @@ export type Admin = {
 };
 export type Binary = string;
 export interface InstantiateMsg {
+  admin?: string | null;
   group: Group;
 }
 export interface ContractInstantiateMsg {
@@ -27,9 +28,15 @@ export interface ContractInstantiateMsg {
   msg: Binary;
 }
 export type ExecuteMsg = {
+  update_admin: {
+    admin?: string | null;
+  };
+} | {
   distribute: {};
 };
 export type QueryMsg = {
+  admin: {};
+} | {
   group: {};
 } | {
   member: {
@@ -41,6 +48,9 @@ export type QueryMsg = {
     start_after?: string | null;
   };
 };
+export interface AdminResponse {
+  admin?: string | null;
+}
 export type Addr = string;
 export interface MemberListResponse {
   members: Member[];


Test patch:
diff --git a/test-suite/src/splits/tests/integration_tests.rs b/test-suite/src/splits/tests/integration_tests.rs
index 4288a171..418ce3f7 100644
--- a/test-suite/src/splits/tests/integration_tests.rs
+++ b/test-suite/src/splits/tests/integration_tests.rs
@@ -36,6 +36,7 @@ mod tests {
         let flex_id = app.store_code(contract_splits());
         let msg = sg_splits::msg::InstantiateMsg {
             group: Group::Cw4Address(group_addr.to_string()),
+            admin: None,
         };
         app.instantiate_contract(flex_id, Addr::unchecked(OWNER), &msg, &[], "splits", None)
             .unwrap()
@@ -61,6 +62,7 @@ mod tests {
                 admin: None,
                 label: "cw4-group".to_string(),
             }),
+            admin: Some(OWNER.into()),
         };
         app.instantiate_contract(flex_id, Addr::unchecked(OWNER), &msg, &[], "splits", None)
             .unwrap()
@@ -140,6 +142,7 @@ mod tests {
         // Zero weight fails
         let instantiate_msg = InstantiateMsg {
             group: Group::Cw4Address(group_addr.to_string()),
+            admin: None,
         };
         let err = app
             .instantiate_contract(
@@ -161,6 +164,7 @@ mod tests {
 
         let instantiate_msg = InstantiateMsg {
             group: Group::Cw4Address(group_addr.to_string()),
+            admin: None,
         };
         let splits_addr = app
             .instantiate_contract(
diff --git a/test-suite/src/vending_minter/tests/splits.rs b/test-suite/src/vending_minter/tests/splits.rs
index 4998d0ee..d0f8b313 100644
--- a/test-suite/src/vending_minter/tests/splits.rs
+++ b/test-suite/src/vending_minter/tests/splits.rs
@@ -35,6 +35,7 @@ fn instantiate_splits(app: &mut StargazeApp, group_addr: Addr) -> Addr {
     println!("splits_id: {}", splits_id);
     let msg = sg_splits::msg::InstantiateMsg {
         group: Group::Cw4Address(group_addr.to_string()),
+        admin: None,
     };
     app.instantiate_contract(splits_id, Addr::unchecked(OWNER), &msg, &[], "splits", None)
         .unwrap()