Decentralised Venture Funding

💡
This contract is for educational purposes only. It has not been audited.

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.

💡
You can support my work at alexandercodes.algo

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.

💡
ARC-59 is the brainchild of Joe Polny (@joe-p) and Brian Whippo (@silentrhetoric).

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"