Decentralised Venture Funding
Introduction
In this blog post, we'll explore how to write a venture funding smart contract in Algorand Python.
The aim is to provide a secure way for investors to pledge support to a project, without relying on a trusted intermediary.
Participants' funds are held in separate, non-custodial accounts until the funding round closes.
This design pattern is geared towards larger investors and institutions.
Terminology
Each project has a funding target and a deadline.
The target is the minimum amount of money that needs to be raised in order for the project to access the funds.
Backers are potential investors who pledge their support to the project for the duration of the funding round.
If the target is not met by the deadline, backers can reclaim their funds.
A backer is considered an investor once the project has withdrawn the funds they contributed.
Each investor receives an NFT to certify their participation in the funding round.
Creating a Project
Information about the project will be stored in the application's global state:
class VentureFunding(ARC4Contract):
"""A smart contract for decentralised venture funding."""
@arc4.abimethod(create="require")
def new_project(
self,
project_name: String,
funding_target: UInt64,
funding_deadline: UInt64,
minimum_pledge: UInt64,
) -> None:
"""Creates a new venture funding project.
Args:
project_name (String): The name of the project.
funding_target (UInt64): The funding target amount in MicroAlgos.
funding_deadline (UInt64): The round that the target amount must be raised by, in order for the project to access the funds.
minimum_pledge (UInt64): The minimum amount of MicroAlgos that a backer can pledge to the project.
"""
assert funding_deadline > Global.round, "Funding deadline must be in the future"
assert funding_target > minimum_pledge, "Funding target must be > the minimum pledge"
assert minimum_pledge >= Global.min_balance, "Minimum pledge must be >= the minimum account balance"
self.project_name = project_name
self.funding_target = funding_target
self.funding_deadline = funding_deadline
self.minimum_pledge = minimum_pledge
self.pledged_amount = UInt64(0)
This makes it easy to query off chain, without incurring transaction fees.
The total amount pledged is initially set to zero.
Design Patterns for Storing Funds
Backers need to be able to pledge money to the project, and withdraw it if the funding target isn't met by the deadline.
This would usually be implemented by pooling all the funds in the application account, and tracking each backer's contribution in local state.
But I want to show you a nifty design pattern from ARC-59: the vault factory.
A separate account (called a vault) will be created for each backer, ensuring that their funds remain isolated until the deadline is reached.
Whilst there are trade-offs in this approach, it should provide greater security guarantees and may even be necessary in some regulatory environments.
So how does it work?
The Vault Factory
The vault factory is a separate contract with a single responsibility: creating vault accounts.
It has a single method that creates a new application and rekeys the account to the transaction sender:
class VaultFactory(ARC4Contract):
"""A contract that creates an account and rekeys it to the sender."""
@arc4.abimethod(create="require", allow_actions=[OnCompleteAction.DeleteApplication])
def new(self) -> arc4.Address:
"""Creates a new account and rekeys it to the sender.
Returns:
arc4.Address: The vault address.
"""
return arc4.Address(itxn.Payment(receiver=Txn.sender, rekey_to=Txn.sender, fee=0).submit().sender)
Rekeying is a powerful protocol feature which enables an Algorand account holder to maintain a static public address while dynamically rotating the authoritative private spending key(s).
Source: https://developer.algorand.org/docs/get-details/accounts/rekey/
The venture funding contract can call the vault factory using an arc4.abi_call
:
address, _txn = arc4.abi_call(
VaultFactory.new,
approval_program=VAULT_FACTORY_APPROVAL,
clear_state_program=VAULT_FACTORY_CLEAR,
on_completion=OnCompleteAction.DeleteApplication,
fee=0,
)
To get this to work, I had to compile the vault factory contract to TEAL and get the program bytes:
VAULT_FACTORY_APPROVAL = b'\n \x01\x01\x80\x04V\x1d/\xea6\x1a\x00\x8e\x01\x00\x01\x001\x19\x81\x05\x12D1\x18\x14D\x88\x00\x0b\x80\x04\x15\x1f|uLP\xb0"C\x8a\x00\x01\xb11\x00I\x81\x00\xb2\x01\xb2 \xb2\x07"\xb2\x10\xb3\xb4\x00\x89'
VAULT_FACTORY_CLEAR = b"\n\x81\x01C"
There might be a better way to do this - maybe referencing the approval program with something like VaultFactory.approval_program()
.
If anyone knows how to do this, please leave a comment!
Mapping Backers to Vaults
It's important that there's a one-to-one correspondence between the set of backers \(B\), and the set of vaults \(V\).
If we take \(X\) to be the set of all accounts, \(B \subset X\) and \(V \subset X\).
The venture funding contract account \(C\) is neither a backer nor a vault:
$$C \in X, \ C \not\in B, \ C \not\in V$$
And \(B\) and \(V\) should have no elements in common:
$$B \cap V = \emptyset$$
We can use box storage to map backers to vaults:
$$f : B \to V$$
The function should map distinct backers to distinct vaults (one-to-one):
$$f(b_1) = f(b_2) \implies b_1 = b_2$$
$$\forall b_1, \ \forall b_2, \ b_1 \ne b_2 \implies f(b_1) \ne f(b_2)$$
And it should be possible to map to each vault from a backer:
$$\forall v \in V \ \ \exists b \in B \ \mid \ f(b) = v$$
So how can we implement this?
Working With Box Storage
Let's start by creating type aliases to distinguish between backers and vaults:
from typing import TypeAlias
Backer: TypeAlias = Account
Vault: TypeAlias = Account
This step is optional, but it should make the code easier to understand.
Next, let's define a subroutine
that creates a new vault by calling the vault factory contract:
@subroutine
def create_vault(backer: Backer) -> Vault:
"""Creates a new vault account for a backer and saves it in box storage.
Args:
backer (Backer): The backer account.
Returns:
Vault: The vault account.
"""
# Call the vault factory contract to create a new vault
address, _txn = arc4.abi_call(
VaultFactory.new,
approval_program=VAULT_FACTORY_APPROVAL,
clear_state_program=VAULT_FACTORY_CLEAR,
on_completion=OnCompleteAction.DeleteApplication,
fee=0,
)
vault = Account(address.bytes)
# Save (backer, vault) pair in box storage
op.Box.put(backer.bytes, vault.bytes)
return vault
op.Box.put
will create a new box if the key doesn't already exist, otherwise it will replace the contents of the box with the new value.
So before calling this subroutine, we need to make sure that the receiver doesn't already have a vault.
Let's add another subroutine
to find an existing vault:
MaybeVault: TypeAlias = Account # Vault account or the zero address
@subroutine
def find_vault(backer: Backer) -> MaybeVault:
"""Finds the backer's vault, if it exists.
Args:
backer (Backer): The backer to find the vault for.
Returns:
MaybeVault: The vault if found, else the zero address.
"""
maybe_vault, exists = op.Box.get(backer.bytes)
return Account(maybe_vault) if exists else Global.zero_address
The zero addressAccount
is falsey in Algorand Python, so the following statements are equivalent:
if find_vault(backer):
...
# Equivalent to:
if find_vault(backer) != Global.zero_address:
...
Which enables the rather nice syntax:
vault = find_vault(backer) or create_vault(backer)
Making a Pledge
To pledge funds, a backer must make a payment to the venture funding contract.
We can define an ARC-4 ABI method that takes a payment transaction as an argument:
@arc4.abimethod
def pledge(self, payment: gtxn.PaymentTransaction) -> UInt64:
"""Makes a pledge to the project.
Args:
payment (gtxn.PaymentTransaction): The payment transaction transferring the pledged amount to the contract.
Returns:
UInt64: The total amount pledged by the backer.
"""
assert Global.round < self.funding_deadline, "The funding round has closed"
assert payment.receiver == Global.current_application_address, "Payment receiver must be the app address"
assert payment.amount >= self.minimum_pledge, "Payment amount must >= the minimum pledge"
vault = find_vault(payment.sender) or create_vault(payment.sender)
# Pay the pledge to the vault
pay_from(Global.current_application_address, to=vault, amount=payment.amount)
self.pledged_amount += payment.amount
return vault.balance
The code checks that the payment receiver is the venture funding application address, and that the pledged amount is >= the minimum_pledge
.
Then, if the payment is valid, it transfers the money to the backer's vault.
The payment amount gets added to the pledged_amount
in the application's global state, and the current balance of the backer's vault is returned.
A backer can use this method to make more than one pledge - each time increasing the balance in their vault.
There's nothing stopping the backer transferring additional funds directly to their vault either, so there is a possibility that the pledged_amount
in the contract's state is less than the total amount of funds available.
Claiming Refunds
If the funding deadline has passed and the target has not been reached, each backer can withdraw the full balance of their vault.
To close a vault and delete it from box storage:
@subroutine
def close(backer: Backer, vault: Vault, to: Account) -> UInt64:
"""Closes the vault and transfers its balance to the receiver.
Args:
backer (Backer): The backer to close the vault for.
vault (Vault): The vault to close.
to (Account): The account to transfer the funds to.
Returns:
UInt64: The amount transferred to the receiver.
"""
_deleted = op.Box.delete(backer.bytes)
return (
itxn.Payment(
sender=vault,
receiver=to,
amount=vault.balance,
close_remainder_to=to,
fee=0,
)
.submit()
.amount
)
And to claim a refund:
@arc4.abimethod
def claim_refund(self) -> UInt64:
"""Refunds a backer if the funding deadline has passed and the target has not been met.
Returns:
UInt64: The amount of MicroAlgos refunded.
"""
assert Global.round >= self.funding_deadline, "Funding deadline has not passed"
assert self.pledged_amount < self.funding_target, "Funding target has been met"
vault = find_vault(Txn.sender)
assert vault, "Vault not found"
return close(Txn.sender, vault, to=Txn.sender)
Minting Investor Certificates
The project can withdraw funds once the deadline has passed, if the target amount has been raised.
A backer is considered an investor once the project has withdrawn the money from their vault.
To mint a certificate:
@subroutine
def mint_certificate(investor: Account, invested_amount: UInt64) -> Asset:
"""Mints an NFT to certify an investor's participation in the funding round.
Args:
investor (Account): The investor's account.
invested_amount (UInt64): The amount they inested.
Returns:
Asset: The certificate asset.
"""
return (
itxn.AssetConfig(
total=1,
decimals=0,
asset_name="CERT",
unit_name=arc4.UInt64(invested_amount).bytes,
reserve=investor, # Has no authority in the Algorand protocol
fee=0,
)
.submit()
.created_asset
)
We store the number of MicroAlgos that the investor contributed in the unit name of the asset.
The project could use this to distribute rewards and benefits proportionally.
The investor's account gets stored as the 'reserve' address.
This asset parameter has no authority in the Algorand protocol, so I often use it to store an address for some other purpose, in a type-safe way.
Before an investor can receive their certificate, they will need to opt in to the asset.
We can use the reserve address to ensure that the token can only be transferred to the investor:
@arc4.abimethod
def claim_certificate(self, certificate: Asset) -> None:
"""Transfers the certificate to the investor.
Args:
certificate (Asset): The certificate asset to withdraw.
"""
assert Global.round >= self.funding_deadline, "Funding deadline has not passed"
assert self.pledged_amount >= self.funding_target, "Funding target has not been met"
itxn.AssetTransfer(
xfer_asset=certificate,
asset_receiver=certificate.reserve,
asset_amount=1,
fee=0,
).submit()
You could add additional checks here to ensure that only the investor can call this method, but it's probably not necessary.
If the investor hasn't opted in to receive the asset, the transfer will fail.
Withdrawing Funds
If the funding target has been met, the project creator can withdraw the pledged funds from the venture funding account:
@arc4.abimethod
def withdraw_funds_from(self, backer: arc4.Address) -> tuple[UInt64, UInt64]:
"""Closes the backer's vault and transfers its balance to the application creator.
Args:
backer (arc4.Address): The backer's address.
Returns:
tuple[UInt64, UInt64]: The total amount of MicroAlgos withdrawn and the asset ID of the certificate minted.
"""
assert Global.round >= self.funding_deadline, "Funding deadline has not passed"
assert self.pledged_amount >= self.funding_target, "Funding target has not been met"
backer_account = Account(backer.bytes)
vault = find_vault(backer_account)
assert vault, "Vault not found"
invested_amount = close(backer_account, vault, to=Global.creator_address)
certificate = mint_certificate(backer_account, invested_amount)
return invested_amount, certificate.id
Off-chain code can obtain the list of backers' addresses from the application's box storage, and close out their vaults one by one (in a loop, if so desired).
It would be possible to reduce transaction fees slightly by processing multiple payments in a single application call, but the fees are likely inconsequential for any real-world scenario.
Submitting 1,000 transactions costs about $0.20 USD, at the time of writing.
And as they say - premature optimisation is the root of all evil.
I'll show an example of how to automate a full withdrawal in the 'writing tests' section.
The Complete Contract Code
The code below is also available on GitHub.
from typing import TypeAlias
from algopy import (
Account,
ARC4Contract,
Asset,
Global,
OnCompleteAction,
String,
Txn,
UInt64,
arc4,
gtxn,
itxn,
op,
subroutine,
)
Backer: TypeAlias = Account
Vault: TypeAlias = Account
MaybeVault: TypeAlias = Account # Vault account or the zero address
class VaultFactory(ARC4Contract):
"""A contract that creates an account and rekeys it to the sender."""
@arc4.abimethod(create="require", allow_actions=[OnCompleteAction.DeleteApplication])
def new(self) -> arc4.Address:
"""Creates a new account and rekeys it to the sender.
Returns:
arc4.Address: The vault address.
"""
return arc4.Address(itxn.Payment(receiver=Txn.sender, rekey_to=Txn.sender, fee=0).submit().sender)
# Compiled TEAL code for the VaultFactory contract
VAULT_FACTORY_APPROVAL = b'\n \x01\x01\x80\x04V\x1d/\xea6\x1a\x00\x8e\x01\x00\x01\x001\x19\x81\x05\x12D1\x18\x14D\x88\x00\x0b\x80\x04\x15\x1f|uLP\xb0"C\x8a\x00\x01\xb11\x00I\x81\x00\xb2\x01\xb2 \xb2\x07"\xb2\x10\xb3\xb4\x00\x89'
VAULT_FACTORY_CLEAR = b"\n\x81\x01C"
@subroutine
def pay_from(sender: Account, /, *, to: Account, amount: UInt64) -> None:
"""Makes a payment from the sender to the receiver.
Args:
sender (Account): The account to send the payment from.
to (Account): The account to send the payment to.
amount (UInt64): The amount of MicroAlgos to pay to the receiver.
"""
itxn.Payment(
sender=sender,
receiver=to,
amount=amount,
fee=0,
).submit()
@subroutine
def create_vault(backer: Backer) -> Vault:
"""Creates a new vault account for a backer and saves it in box storage.
Args:
backer (Backer): The backer account.
Returns:
Vault: The vault account.
"""
# Call the vault factory contract to create a new vault
address, _txn = arc4.abi_call(
VaultFactory.new,
approval_program=VAULT_FACTORY_APPROVAL,
clear_state_program=VAULT_FACTORY_CLEAR,
on_completion=OnCompleteAction.DeleteApplication,
fee=0,
)
vault = Account(address.bytes)
# Save (backer, vault) pair in box storage
op.Box.put(backer.bytes, vault.bytes)
return vault
@subroutine
def find_vault(backer: Backer) -> MaybeVault:
"""Finds the backer's vault, if it exists.
Args:
backer (Backer): The backer to find the vault for.
Returns:
MaybeVault: The vault if found, else the zero address.
"""
maybe_vault, exists = op.Box.get(backer.bytes)
return Account(maybe_vault) if exists else Global.zero_address
@subroutine
def close(backer: Backer, vault: Vault, to: Account) -> UInt64:
"""Closes the vault and transfers its balance to the receiver.
Args:
backer (Backer): The backer to close the vault for.
vault (Vault): The vault to close.
to (Account): The account to transfer the funds to.
Returns:
UInt64: The amount transferred to the receiver.
"""
_deleted = op.Box.delete(backer.bytes)
return (
itxn.Payment(
sender=vault,
receiver=to,
amount=vault.balance,
close_remainder_to=to,
fee=0,
)
.submit()
.amount
)
@subroutine
def mint_certificate(investor: Account, invested_amount: UInt64) -> Asset:
"""Mints an NFT to certify an investor's participation in the funding round.
Args:
investor (Account): The investor's account.
invested_amount (UInt64): The amount they inested.
Returns:
Asset: The certificate asset.
"""
return (
itxn.AssetConfig(
total=1,
decimals=0,
asset_name="CERT",
unit_name=arc4.UInt64(invested_amount).bytes,
reserve=investor, # Has no authority in the Algorand protocol
fee=0,
)
.submit()
.created_asset
)
class VentureFunding(ARC4Contract):
"""A smart contract for decentralised venture funding."""
@arc4.abimethod(create="require")
def new_project(
self,
project_name: String,
funding_target: UInt64,
funding_deadline: UInt64,
minimum_pledge: UInt64,
) -> None:
"""Creates a new venture funding project.
Args:
project_name (String): The name of the project.
funding_target (UInt64): The funding target amount in MicroAlgos.
funding_deadline (UInt64): The round that the target amount must be raised by, in order for the project to access the funds.
minimum_pledge (UInt64): The minimum amount of MicroAlgos that a backer can pledge to the project.
"""
assert funding_deadline > Global.round, "Funding deadline must be in the future"
assert funding_target > minimum_pledge, "Funding target must be > the minimum pledge"
assert minimum_pledge >= Global.min_balance, "Minimum pledge must be >= the minimum account balance"
self.project_name = project_name
self.funding_target = funding_target
self.funding_deadline = funding_deadline
self.minimum_pledge = minimum_pledge
self.pledged_amount = UInt64(0)
@arc4.abimethod
def pledge(self, payment: gtxn.PaymentTransaction) -> UInt64:
"""Makes a pledge to the project.
Args:
payment (gtxn.PaymentTransaction): The payment transaction transferring the pledged amount to the contract.
Returns:
UInt64: The total amount pledged by the backer.
"""
assert Global.round < self.funding_deadline, "The funding round has closed"
assert payment.receiver == Global.current_application_address, "Payment receiver must be the app address"
assert payment.amount >= self.minimum_pledge, "Payment amount must >= the minimum pledge"
vault = find_vault(payment.sender) or create_vault(payment.sender)
# Pay the pledge to the vault
pay_from(Global.current_application_address, to=vault, amount=payment.amount)
self.pledged_amount += payment.amount
return vault.balance
@arc4.abimethod
def claim_refund(self) -> UInt64:
"""Refunds a backer if the funding deadline has passed and the target has not been met.
Returns:
UInt64: The amount of MicroAlgos refunded.
"""
assert Global.round >= self.funding_deadline, "Funding deadline has not passed"
assert self.pledged_amount < self.funding_target, "Funding target has been met"
vault = find_vault(Txn.sender)
assert vault, "Vault not found"
return close(Txn.sender, vault, to=Txn.sender)
@arc4.abimethod
def withdraw_funds_from(self, backer: arc4.Address) -> tuple[UInt64, UInt64]:
"""Closes the backer's vault and transfers its balance to the application creator.
Args:
backer (arc4.Address): The backer's address.
Returns:
tuple[UInt64, UInt64]: The total amount of MicroAlgos withdrawn and the asset ID of the certificate minted.
"""
assert Global.round >= self.funding_deadline, "Funding deadline has not passed"
assert self.pledged_amount >= self.funding_target, "Funding target has not been met"
backer_account = Account(backer.bytes)
vault = find_vault(backer_account)
assert vault, "Vault not found"
invested_amount = close(backer_account, vault, to=Global.creator_address)
certificate = mint_certificate(backer_account, invested_amount)
return invested_amount, certificate.id
@arc4.abimethod
def claim_certificate(self, certificate: Asset) -> None:
"""Transfers the certificate to the investor.
Args:
certificate (Asset): The certificate asset to withdraw.
"""
assert Global.round >= self.funding_deadline, "Funding deadline has not passed"
assert self.pledged_amount >= self.funding_target, "Funding target has not been met"
itxn.AssetTransfer(
xfer_asset=certificate,
asset_receiver=certificate.reserve,
asset_amount=1,
fee=0,
).submit()
Writing Tests
The tests below are not exhaustive.
Please only use them as a starting point for your own tests, or to see how you can interact with the smart contract from off-chain code.
The tests below are also available on GitHub.
import base64
import time
from functools import reduce
import pytest
from algokit_utils import (
Account,
EnsureBalanceParameters,
TransactionParameters,
TransferParameters,
ensure_funded,
get_account,
transfer,
)
from algokit_utils.config import config
from algosdk import encoding
from algosdk.atomic_transaction_composer import (
AccountTransactionSigner,
TransactionWithSigner,
)
from algosdk.transaction import PaymentTxn, SuggestedParams
from algosdk.util import algos_to_microalgos
from algosdk.v2client.algod import AlgodClient
from algosdk.v2client.indexer import IndexerClient
from smart_contracts.artifacts.venture_funding.venture_funding_client import VentureFundingClient
@pytest.fixture(scope="session")
def app_client(account: Account, algod_client: AlgodClient, indexer_client: IndexerClient) -> VentureFundingClient:
config.configure(
debug=True,
# trace_all=True,
)
client = VentureFundingClient(
algod_client,
creator=account,
indexer_client=indexer_client,
)
last_round = algod_client.status()["last-round"]
client.create_new_project(
project_name="Test Project",
funding_target=algos_to_microalgos(1_000),
funding_deadline=last_round + 6,
minimum_pledge=algos_to_microalgos(10),
)
transfer(
algod_client,
TransferParameters(
from_account=account,
to_address=client.app_address,
micro_algos=1_000_000,
),
)
return client
def flat_fee(algod_client: AlgodClient, fee: int) -> SuggestedParams:
sp = algod_client.suggested_params()
sp.fee = fee
sp.flat_fee = True
return sp
def test_new_project(app_client: VentureFundingClient) -> None:
"""Tests the new_project() method."""
state = {k: getattr(v, "as_str", v) for k, v in app_client.get_global_state().__dict__.items()}
assert state["project_name"] == "Test Project", "Incorrect project name"
assert state["funding_target"] == algos_to_microalgos(1_000), "Incorrect funding target"
assert isinstance(state["funding_deadline"], int) and state["funding_deadline"] > 0, "Invalid funding deadline"
assert state["minimum_pledge"] == algos_to_microalgos(10), "Incorrect minimum pledge"
assert isinstance(state["pledged_amount"], int) and state["pledged_amount"] == 0, "Incorrect initial pledged amount"
def test_pledge(account: Account, app_client: VentureFundingClient) -> None:
"""Tests the pledge() method."""
txn = PaymentTxn(
sender=account.address,
receiver=app_client.app_address,
amt=algos_to_microalgos(75),
sp=app_client.algod_client.suggested_params(),
)
signer = AccountTransactionSigner(account.private_key)
pledged_amount = app_client.pledge(
payment=TransactionWithSigner(txn, signer),
transaction_parameters=TransactionParameters(
suggested_params=flat_fee(app_client.algod_client, 4_000),
boxes=[(0, encoding.decode_address(account.address))],
),
).return_value
assert pledged_amount == algos_to_microalgos(75), "Incorrect pledge amount value returned"
assert app_client.get_global_state().pledged_amount == algos_to_microalgos(
75
), "Incorrect pledged amount in global state"
def test_withdraw_funds(account: Account, app_client: VentureFundingClient, indexer_client: IndexerClient) -> None:
"""Tests the withdraw_funds() method."""
target_delta = app_client.get_global_state().funding_target - app_client.get_global_state().pledged_amount
# Generate new account to simulate a second backer
backer_b = get_account(app_client.algod_client, "backer_b")
parameters = EnsureBalanceParameters(
account_to_fund=backer_b,
min_spending_balance_micro_algos=target_delta + 10_000, # target delta + buffer for fees
)
ensure_funded(app_client.algod_client, parameters)
txn = PaymentTxn(
sender=backer_b.address,
receiver=app_client.app_address,
amt=target_delta,
sp=app_client.algod_client.suggested_params(),
)
signer = AccountTransactionSigner(backer_b.private_key)
backer_b_bytes = encoding.decode_address(backer_b.address)
# Fund the remaining amount to meet the target
app_client.pledge(
payment=TransactionWithSigner(txn, signer),
transaction_parameters=TransactionParameters(
suggested_params=flat_fee(app_client.algod_client, 4_000), boxes=[(0, backer_b_bytes)]
),
).return_value
assert (
app_client.get_global_state().funding_target - app_client.get_global_state().pledged_amount == 0
), "Funding target not met"
backers = [
base64.b64decode(x["name"]) for x in app_client.algod_client.application_boxes(app_client.app_id)["boxes"]
]
time.sleep(5) # Wait for indexer to catch up
backer_vault_map = {
backer: base64.b64decode(indexer_client.application_box_by_name(app_client.app_id, backer)["value"])
for backer in backers
}
get_balance = lambda a: app_client.algod_client.account_info(a)["amount"]
total_vault_balance = reduce(
lambda balance, vault: balance + get_balance(encoding.encode_address(vault)), backer_vault_map.values(), 0
)
withdrawn_amount = 0
creator_balance_start = get_balance(account.address)
for backer, vault in backer_vault_map.items():
withdrawn_amount += app_client.withdraw_funds_from(
backer=backer,
transaction_parameters=TransactionParameters(
accounts=[encoding.encode_address(backer), encoding.encode_address(vault)],
boxes=[(0, backer)],
suggested_params=flat_fee(app_client.algod_client, 4_000),
),
).return_value[0] # the invested amount
assert withdrawn_amount == total_vault_balance, "Incorrect withdrawn amount values returned"
assert (
creator_balance_start + withdrawn_amount >= get_balance(account.address) - 10_000
), "Incorrect creator balance after withdrawing funds"