Skip to main content
Full working code: The complete source code for this example is available in example-contracts/shared-account/
This example demonstrates a simple multi-tenant shared account introducing static contract imports, complex storage structs, authorization logic, ID generation, and cross-contract calls.

WIT Interface

Exports functions for multi-tenant account management with token deposits and withdrawals:
package root:component;

world root {
  include kontor:built-in/built-in;
  use kontor:built-in/context.{view-context, proc-context, signer};
  use kontor:built-in/error.{error};
  use kontor:built-in/foreign.{contract-address};
  use kontor:built-in/numbers.{integer, decimal};

  export init: func(ctx: borrow<proc-context>);

  export open: func(ctx: borrow<proc-context>, token: contract-address, n: integer, other-tenants: list<string>) -> result<string, error>;

  export deposit: func(ctx: borrow<proc-context>, token: contract-address, account-id: string, n: integer) -> result<_, error>;

  export withdraw: func(ctx: borrow<proc-context>, token: contract-address, account-id: string, n: integer) -> result<_, error>;

  export balance: func(ctx: borrow<view-context>, account-id: string) -> option<integer>;

  export token-balance: func(ctx: borrow<view-context>, token: contract-address, holder: string) -> option<integer>;

  export tenants: func(ctx: borrow<view-context>, account-id: string) -> option<list<string>>;
}

Rust Implementation

  • The interface! macro generates an interface for cross-contract calls to the token contract with a dynamic address (passed as a parameter). Use import! when the dependency contract has been previously published and the contract address is known at compile time.
  • The StorageRoot macro, used for the root storage type, and the Storage macro, used for nested storage types in the Account struct, enable persistent storage.
  • other_tenants uses Map<String, bool> because the storage layer does not currently support list types, and Map provides a limited interface with the keys method for iteration. Even with a List type using a Map here could make sense. Instead of bool an enum or struct that defines the their “role” in the account could be written.
  • authorized Verifies procedure permissions
  • open: Verifies token balance, generates an ID using crypto::generate_id(), sets the account, and transfers tokens to ctx.contract_signer()
  • deposit/withdraw: Authorize the caller, verify balances, update storage, and call the token contract following CEI pattern
  • balance/tenants: Query the storage for account details
use stdlib::*;

contract!(name = "shared-account");

interface!(name = "token", path = "../token/contract/wit");

#[derive(Clone, Default, Storage)]
struct Account {
    pub other_tenants: Map<String, bool>,
    pub balance: Integer,
    pub owner: String,
}

#[derive(Clone, Default, StorageRoot)]
struct SharedAccountStorage {
    pub accounts: Map<String, Account>,
}

fn authorized(signer: &Signer, account: &AccountModel) -> bool {
    account.owner() == signer.to_string()
        || account
            .other_tenants()
            .get(signer.to_string())
            .is_some_and(|b| b)
}

fn insufficient_balance_error() -> Error {
    Error::Message("insufficient balance".to_string())
}

fn unauthorized_error() -> Error {
    Error::Message("unauthorized".to_string())
}

fn unknown_error() -> Error {
    Error::Message("unknown account".to_string())
}

impl Guest for SharedAccount {
    fn init(ctx: &ProcContext) {
        SharedAccountStorage::default().init(ctx);
    }

    fn open(
        ctx: &ProcContext,
        token: ContractAddress,
        n: Integer,
        other_tenants: Vec<String>,
    ) -> Result<String, Error> {
        let signer = ctx.signer();
        let balance =
            token::balance(&token, &signer.to_string()).ok_or(insufficient_balance_error())?;
        if balance < n {
            return Err(insufficient_balance_error());
        }
        let account_id = ctx.generate_id();
        ctx.model().accounts().set(
            account_id.clone(),
            Account {
                balance: n,
                owner: ctx.signer().to_string(),
                other_tenants: Map::new(
                    &other_tenants
                        .into_iter()
                        .map(|t| (t, true))
                        .collect::<Vec<_>>(),
                ),
            },
        );
        token::transfer(&token, signer, &ctx.contract_signer().to_string(), n)?;
        Ok(account_id)
    }

    fn deposit(
        ctx: &ProcContext,
        token: ContractAddress,
        account_id: String,
        n: Integer,
    ) -> Result<(), Error> {
        let signer = ctx.signer();
        let balance =
            token::balance(&token, &signer.to_string()).ok_or(insufficient_balance_error())?;
        if balance < n {
            return Err(insufficient_balance_error());
        }
        let account = ctx
            .model()
            .accounts()
            .get(account_id)
            .ok_or(unknown_error())?;
        if !authorized(&signer, &account) {
            return Err(unauthorized_error());
        }
        account.update_balance(|b| b + n);
        token::transfer(&token, signer, &ctx.contract_signer().to_string(), n)
    }

    fn withdraw(
        ctx: &ProcContext,
        token: ContractAddress,
        account_id: String,
        n: Integer,
    ) -> Result<(), Error> {
        let signer = ctx.signer();
        let account = ctx
            .model()
            .accounts()
            .get(account_id)
            .ok_or(unknown_error())?;
        if !authorized(&signer, &account) {
            return Err(unauthorized_error());
        }
        let balance = account.balance();
        if balance < n {
            return Err(insufficient_balance_error());
        }
        account.set_balance(balance - n);
        token::transfer(&token, ctx.contract_signer(), &signer.to_string(), n)
    }

    fn balance(ctx: &ViewContext, account_id: String) -> Option<Integer> {
        ctx.model().accounts().get(account_id).map(|a| a.balance())
    }

    fn token_balance(
        _ctx: &ViewContext,
        token: ContractAddress,
        holder: String,
    ) -> Option<Integer> {
        token::balance(&token, &holder)
    }

    fn tenants(ctx: &ViewContext, account_id: String) -> Option<Vec<String>> {
        ctx.model().accounts().get(account_id).map(|a| {
            [a.owner()]
                .into_iter()
                .chain(a.other_tenants().keys())
                .collect()
        })
    }
}
Key points:
  • Uses interface! not import! for dynamic token contract address
  • ctx.model() for storage access
  • Map operations: accounts().get(id) returns Option<AccountModel>
  • Nested struct updates: account.update_balance(|b| b + n)
  • Error helpers return Error::Message(...to_string())
  • authorized() function takes &AccountModel not &AccountWrapper
  • ctx.generate_id() creates unique account IDs
  • Calls token::balance() and token::transfer() with contract addresses

Testing

The test demonstrates creating accounts, depositing, withdrawing, and testing authorization:
#[cfg(test)]
mod tests {
    use testlib::*;

    interface!(name = "token", path = "../token/contract/wit");
    interface!(name = "shared-account");

    #[testlib::test]
    async fn test_shared_account_contract() -> Result<()> {
        let alice = runtime.identity().await?;
        let bob = runtime.identity().await?;
        let claire = runtime.identity().await?;
        let dara = runtime.identity().await?;

        let token = runtime.publish(&alice, "token").await?;
        let shared_account = runtime.publish(&alice, "shared-account").await?;

        token::mint(runtime, &token, &alice, 100.into()).await??;

        let account_id = shared_account::open(
            runtime,
            &shared_account,
            &alice,
            token.clone(),
            50.into(),
            vec![&bob, &dara],
        )
        .await??;

        let result = shared_account::balance(runtime, &shared_account, &account_id).await?;
        assert_eq!(result, Some(50.into()));

        shared_account::deposit(runtime, &shared_account, &alice, token.clone(), &account_id, 25.into())
            .await??;

        let result = shared_account::balance(runtime, &shared_account, &account_id).await?;
        assert_eq!(result, Some(75.into()));

        shared_account::withdraw(runtime, &shared_account, &bob, token.clone(), &account_id, 25.into())
            .await??;

        let result = shared_account::balance(runtime, &shared_account, &account_id).await?;
        assert_eq!(result, Some(50.into()));

        shared_account::withdraw(runtime, &shared_account, &alice, token.clone(), &account_id, 50.into())
            .await??;

        let result = shared_account::balance(runtime, &shared_account, &account_id).await?;
        assert_eq!(result, Some(0.into()));

        let result = shared_account::withdraw(runtime, &shared_account, &bob, token.clone(), &account_id, 1.into())
            .await?;
        assert_eq!(
            result,
            Err(Error::Message("insufficient balance".to_string()))
        );

        let result = shared_account::withdraw(runtime, &shared_account, &claire, token.clone(), &account_id, 1.into())
            .await?;
        assert_eq!(result, Err(Error::Message("unauthorized".to_string())));

        let result =
            shared_account::token_balance(runtime, &shared_account, token.clone(), &alice).await?;
        assert_eq!(result, Some(75.into()));

        let result = token::balance(runtime, &token, &bob).await?;
        assert_eq!(result, Some(25.into()));

        let result = shared_account::tenants(runtime, &shared_account, &account_id)
            .await?
            .unwrap();
        assert_eq!(result.iter().len(), 3);
        assert!(result.contains(&alice.to_string()));
        assert!(result.contains(&dara.to_string()));
        assert!(result.contains(&bob.to_string()));

        Ok(())
    }
}