Access Control Patterns
Access control determines who can call which functions. In Xian, this is built on two context values: ctx.caller (the immediate caller) and ctx.signer (the original transaction signer).
Owner-Only Functions
The most common pattern. Store the deployer's address at construction time and check it on sensitive operations:
owner = Variable()
@construct
def seed():
owner.set(ctx.caller)
@export
def set_config(key: str, value: str):
assert ctx.caller == owner.get(), "Only the owner can configure"
config[key] = value
@export
def transfer_ownership(new_owner: str):
assert ctx.caller == owner.get(), "Only the owner can transfer ownership"
owner.set(new_owner)Preventing Cross-Contract Calls
Some functions should only be callable by end users (externally owned accounts), not by other contracts. Check that ctx.caller equals ctx.signer:
@export
def claim_airdrop():
assert ctx.caller == ctx.signer, "Must be called directly, not from another contract"
assert not claimed[ctx.caller], "Already claimed"
claimed[ctx.caller] = True
balances[ctx.caller] += airdrop_amount.get()Why this matters: if another contract calls your function, ctx.caller is that contract's name. A malicious contract could exploit this to act on behalf of its caller in unexpected ways.
Allowance Pattern (Approve / Transfer From)
The standard pattern for letting a third party (often another contract) spend tokens on your behalf:
balances = Hash(default_value=0)
@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] += amountHow it works:
- Alice calls
approve(amount=100, to="con_dex")-- this gives the DEX permission to spend 100 of Alice's tokens - The DEX calls
transfer_from(amount=50, to="con_dex", main_account="alice")-- inside the token contract,ctx.calleris"con_dex", which matches the approval
Role-Based Access
For contracts that need more than one privileged role:
owner = Variable()
operators = Hash(default_value=False)
@construct
def seed():
owner.set(ctx.caller)
@export
def add_operator(address: str):
assert ctx.caller == owner.get(), "Only owner"
operators[address] = True
@export
def remove_operator(address: str):
assert ctx.caller == owner.get(), "Only owner"
operators[address] = False
@export
def execute_trade(pair: str, amount: float):
assert operators[ctx.caller] or ctx.caller == owner.get(), "Not authorized"
# ... trade logicMulti-Signature Pattern
Require multiple approvals before executing a sensitive action:
signers = Hash(default_value=False)
signer_count = Variable()
threshold = Variable()
proposals = Hash()
approvals = Hash(default_value=0)
executed = Hash(default_value=False)
@construct
def seed():
signers[ctx.caller] = True
signer_count.set(1)
threshold.set(1)
@export
def add_signer(address: str):
assert signers[ctx.caller], "Not a signer"
assert not signers[address], "Already a signer"
signers[address] = True
signer_count.set(signer_count.get() + 1)
@export
def set_threshold(new_threshold: int):
assert signers[ctx.caller], "Not a signer"
assert new_threshold > 0, "Threshold must be positive"
assert new_threshold <= signer_count.get(), "Threshold exceeds signer count"
threshold.set(new_threshold)
@export
def propose(proposal_id: str, action: str):
assert signers[ctx.caller], "Not a signer"
assert proposals[proposal_id] is None, "Proposal already exists"
proposals[proposal_id] = action
approvals[proposal_id] = 0
@export
def approve_proposal(proposal_id: str):
assert signers[ctx.caller], "Not a signer"
assert proposals[proposal_id] is not None, "Proposal not found"
assert not executed[proposal_id], "Already executed"
assert not approvals[proposal_id, ctx.caller], "Already approved"
approvals[proposal_id, ctx.caller] = True
approvals[proposal_id] += 1
if approvals[proposal_id] >= threshold.get():
executed[proposal_id] = True
return True
return FalseTime-Locked Actions
Require a waiting period before executing sensitive operations:
pending_action = Variable()
action_unlock_time = Variable()
lock_duration = Variable()
@construct
def seed():
owner.set(ctx.caller)
lock_duration.set(datetime.timedelta(days=2))
@export
def propose_action(action: str):
assert ctx.caller == owner.get(), "Only owner"
pending_action.set(action)
action_unlock_time.set(now + lock_duration.get())
@export
def execute_action():
assert ctx.caller == owner.get(), "Only owner"
assert pending_action.get() is not None, "No pending action"
assert now >= action_unlock_time.get(), "Action is still locked"
action = pending_action.get()
pending_action.set(None)
# ... execute the actionCombining Patterns
Real contracts often combine several patterns. A production token might use owner-only for admin functions, allowances for third-party transfers, and direct-call restriction on airdrops:
owner = Variable()
balances = Hash(default_value=0)
@construct
def seed():
owner.set(ctx.caller)
balances[ctx.caller] = 1_000_000
@export
def mint(amount: float):
# Owner-only
assert ctx.caller == owner.get(), "Only owner"
assert amount > 0, "Amount must be positive"
balances[owner.get()] += amount
@export
def transfer(amount: float, to: str):
# Anyone can transfer their own tokens
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):
# Allowance for third-party spending
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 claim_airdrop():
# Direct calls only (no contracts)
assert ctx.caller == ctx.signer, "No contract calls"
assert not claimed[ctx.caller], "Already claimed"
claimed[ctx.caller] = True
balances[ctx.caller] += 100