Testing Multi-Contract Interactions
Many contracts depend on other contracts -- a DEX imports a token, a game imports a currency, a governance contract imports a staking module. ContractingClient handles this by letting you submit multiple contracts and test their interactions.
The Key Rule
Submit contracts in dependency order. If contract B imports contract A, submit A first.
Basic Example: DEX Importing a Token
Define the Token Contract
def token_contract():
balances = Hash(default_value=0)
@construct
def seed():
balances[ctx.caller] = 1_000_000
@export
def transfer(amount: float, to: str):
assert amount > 0, "Amount must be positive"
assert balances[ctx.caller] >= amount, "Insufficient balance"
balances[ctx.caller] -= amount
balances[to] += amount
@export
def approve(amount: float, to: str):
assert amount > 0, "Amount must be positive"
balances[ctx.caller, to] += amount
@export
def transfer_from(amount: float, to: str, main_account: str):
assert amount > 0, "Amount must be positive"
assert balances[main_account, ctx.caller] >= amount, "Not enough approved"
assert balances[main_account] >= amount, "Insufficient balance"
balances[main_account, ctx.caller] -= amount
balances[main_account] -= amount
balances[to] += amount
@export
def balance_of(address: str):
return balances[address]Define the DEX Contract
The DEX imports the token by name. The import statement in the contract source references the name the token was submitted under:
def dex_contract():
import con_token
pools = Hash(default_value=0)
@export
def deposit(amount: float):
con_token.transfer_from(amount=amount, to=ctx.this, main_account=ctx.caller)
pools[ctx.caller] += amount
@export
def withdraw(amount: float):
assert pools[ctx.caller] >= amount, "Insufficient pool balance"
pools[ctx.caller] -= amount
con_token.transfer(amount=amount, to=ctx.caller)
@export
def pool_balance(address: str):
return pools[address]Write the Tests
import unittest
from contracting.client import ContractingClient
class TestDex(unittest.TestCase):
def setUp(self):
self.client = ContractingClient()
self.client.flush()
# Submit token first (the DEX depends on it)
self.client.submit(token_contract, name="con_token")
self.token = self.client.get_contract("con_token")
# Submit DEX second
self.client.submit(dex_contract, name="con_dex")
self.dex = self.client.get_contract("con_dex")
def tearDown(self):
self.client.flush()
def test_deposit(self):
# sys has 1,000,000 tokens from constructor
# Approve the DEX to spend tokens on behalf of sys
self.token.approve(amount=5000, to="con_dex")
# Deposit into the DEX
self.dex.deposit(amount=5000)
# Check balances
self.assertEqual(self.dex.pool_balance(address="sys"), 5000)
self.assertEqual(self.token.balance_of(address="con_dex"), 5000)
self.assertEqual(self.token.balance_of(address="sys"), 995_000)
def test_deposit_without_approval_fails(self):
with self.assertRaises(AssertionError):
self.dex.deposit(amount=5000)
def test_withdraw(self):
self.token.approve(amount=5000, to="con_dex")
self.dex.deposit(amount=5000)
self.dex.withdraw(amount=2000)
self.assertEqual(self.dex.pool_balance(address="sys"), 3000)
self.assertEqual(self.token.balance_of(address="sys"), 997_000)
def test_withdraw_exceeds_pool(self):
self.token.approve(amount=1000, to="con_dex")
self.dex.deposit(amount=1000)
with self.assertRaises(AssertionError):
self.dex.withdraw(amount=2000)
def test_multiple_users(self):
# sys deposits
self.token.approve(amount=1000, to="con_dex")
self.dex.deposit(amount=1000)
# Give alice some tokens
self.token.transfer(amount=500, to="alice")
# Alice deposits
self.client.signer = "alice"
self.token.approve(amount=500, to="con_dex")
self.dex.deposit(amount=500)
self.assertEqual(self.dex.pool_balance(address="sys"), 1000)
self.assertEqual(self.dex.pool_balance(address="alice"), 500)
if __name__ == "__main__":
unittest.main()Context Flow in Multi-Contract Calls
Understanding how ctx changes is critical when testing multi-contract interactions:
self.client.signer = "alice"
self.dex.deposit(amount=100)Inside con_dex.deposit():
ctx.caller="alice"(the user called the DEX)ctx.signer="alice"ctx.this="con_dex"
When con_dex calls con_token.transfer_from():
ctx.caller="con_dex"(the DEX is calling the token)ctx.signer="alice"(unchanged)ctx.this="con_token"
This is why the DEX uses transfer_from with main_account=ctx.caller -- inside the token contract, ctx.caller is "con_dex", which must have an allowance from "alice".
Dynamic Imports in Tests
For contracts that use importlib.import_module(), submit the target contract before calling the function that imports it:
def registry_contract():
import importlib
registered = Hash(default_value=False)
@export
def register(token_name: str):
token = importlib.import_module(token_name)
registered[token_name] = True
class TestRegistry(unittest.TestCase):
def setUp(self):
self.client = ContractingClient()
self.client.flush()
self.client.submit(token_contract, name="con_token")
self.client.submit(registry_contract, name="con_registry")
self.registry = self.client.get_contract("con_registry")
def test_register_existing_token(self):
self.registry.register(token_name="con_token")
is_reg = self.client.get_var("con_registry", "registered", arguments=["con_token"])
self.assertTrue(is_reg)Tips
- Always submit dependencies before the contracts that import them.
- Use
client.signerto simulate different users in the same test. - Use
client.set_var()to set up preconditions (e.g., pre-fund an account) without going through contract functions. - Call
client.flush()between tests to prevent state leakage.