External Functions and Proposals
This guide covers how to use Calimero's external proposal system to interact with external smart contracts, create proposals, and manage the governance system in Calimero applications.
Table of Contents
- Overview
- External Proposal System
- Two Ways to Access External Functions
- Proposal Actions
- Creating Proposals
- Approving Proposals
- Complete Examples
- Best Practices
Overview
Calimero provides an external proposal system that allows your application to:
- Create proposals for external blockchain actions
- Approve proposals through governance
- Execute external function calls to other contracts
- Manage transfers and context modifications (context refers to Calimero Application Networks)
- Implement governance systems with approval workflows
The system is built around the External
struct and DraftProposal
builder
pattern, accessible through two different approaches.
External Proposal System
Core Components
The external proposal system consists of several key components:
use calimero_sdk::env::ext::{External, DraftProposal, ProposalAction, ProposalId};
// External interface for proposal management
let external = External;
// Create a draft proposal
let draft = external.propose();
// Build and send the proposal
let proposal_id = draft
.external_function_call("contract.near", "increment", "{}", 0)
.send();
System Functions
The system provides low-level WASM functions for proposal management:
use calimero_sdk::sys;
// Send a proposal (returns proposal ID)
let proposal_id = unsafe { sys::send_proposal(actions_buffer, result_buffer) };
// Approve a proposal
unsafe { sys::approve_proposal(proposal_id_buffer) };
Two Ways to Access External Functions
Calimero provides two equivalent ways to access external functionality:
1. Via Self::external()
(Macro-Generated)
When you use #[app::state]
on your struct, the macro system automatically
generates a Self::external()
method:
#[app::state]
#[derive(Default, BorshSerialize, BorshDeserialize)]
#[borsh(crate = "calimero_sdk::borsh")]
struct MyApp {
// ... your app state
}
#[app::logic]
impl MyApp {
pub fn create_proposal(&mut self) -> ProposalId {
// ✅ Self::external() is automatically generated by #[app::state] macro
Self::external()
.propose()
.external_function_call("contract.near", "increment", "{}", 0)
.send()
}
pub fn approve_proposal(&mut self, proposal_id: ProposalId) {
// ✅ Self::external() works for approval too
Self::external().approve(proposal_id);
}
}
2. Via Direct External
Usage
You can also use the External
struct directly:
use calimero_sdk::env::ext::External;
#[app::logic]
impl MyApp {
pub fn create_proposal(&mut self) -> ProposalId {
// ✅ Direct External usage
External
.propose()
.external_function_call("contract.near", "increment", "{}", 0)
.send()
}
pub fn approve_proposal(&mut self, proposal_id: ProposalId) {
// ✅ Direct External usage
External::approve(proposal_id);
}
}
3. Both Approaches Are Equivalent
// These two lines do exactly the same thing:
Self::external().approve(proposal_id);
External::approve(proposal_id);
// The macro generates this implementation:
impl MyApp {
fn external() -> ::calimero_sdk::env::ext::External {
::calimero_sdk::env::ext::External {}
}
}
Real-world example: The
demo-blockchain-integrations
repository uses Self::external()
extensively:
// From demo-blockchain-integrations/logic/src/lib.rs
Self::external()
.propose()
.external_function_call(
receiver_id.to_string(),
method_name.to_string(),
args.to_string(),
deposit,
)
.send()
Proposal Actions
Calimero supports several types of proposal actions:
1. External Function Call
Execute a function on an external contract:
use calimero_sdk::env::ext::{ProposalAction, AccountId};
let action = ProposalAction::ExternalFunctionCall {
receiver_id: AccountId("contract.near".to_string()),
method_name: "increment".to_string(),
args: "{}".to_string(),
deposit: 0,
};
2. Transfer
Transfer tokens to an account:
let action = ProposalAction::Transfer {
receiver_id: AccountId("user.near".to_string()),
amount: 1_000_000_000_000_000_000_000_000, // 1 NEAR
};
3. Context Configuration
Modify application context settings:
// Set number of required approvals
let action = ProposalAction::SetNumApprovals {
num_approvals: 3,
};
// Set active proposals limit
let action = ProposalAction::SetActiveProposalsLimit {
active_proposals_limit: 10,
};
// Set context value
let action = ProposalAction::SetContextValue {
key: b"config_key".to_vec().into_boxed_slice(),
value: b"config_value".to_vec().into_boxed_slice(),
};
4. Proposal Management
Delete existing proposals:
let action = ProposalAction::DeleteProposal {
proposal_id: ProposalId([0u8; 32]),
};
Creating Proposals
Using DraftProposal Builder
The recommended way to create proposals is using the DraftProposal
builder
pattern:
use calimero_sdk::env::ext::{External, DraftProposal, AccountId};
#[app::logic]
impl MyApp {
// Using Self::external() (macro-generated)
pub fn create_transfer_proposal_via_self(&mut self, receiver: String, amount: u128) -> ProposalId {
Self::external()
.propose()
.transfer(AccountId(receiver), amount)
.send()
}
// Using External directly
pub fn create_transfer_proposal_via_external(&mut self, receiver: String, amount: u128) -> ProposalId {
External
.propose()
.transfer(AccountId(receiver), amount)
.send()
}
pub fn create_function_call_proposal(
&mut self,
receiver_id: String,
method_name: String,
args: String,
deposit: u128,
) -> ProposalId {
// Both approaches work identically
Self::external()
.propose()
.external_function_call(receiver_id, method_name, args, deposit)
.send()
}
pub fn create_context_modification_proposal(
&mut self,
key: Vec<u8>,
value: Vec<u8>,
) -> ProposalId {
Self::external()
.propose()
.set_context_value(key.into_boxed_slice(), value.into_boxed_slice())
.send()
}
}
Advanced Proposal Creation with Multiple Actions
Create complex proposals with multiple actions:
#[app::logic]
impl MyApp {
pub fn create_complex_proposal(&mut self) -> ProposalId {
Self::external()
.propose()
.transfer(AccountId("treasury.near".to_string()), 1_000_000_000_000_000_000_000_000)
.external_function_call(
"governance.near".to_string(),
"propose_upgrade".to_string(),
r#"{"new_code_hash": "abc123"}"#.to_string(),
0,
)
.set_num_approvals(5)
.set_active_proposals_limit(20)
.send()
}
}
Manual Proposal Creation
For advanced use cases, you can create proposals manually:
use calimero_sdk::env::ext::{ProposalAction, ProposalId};
use calimero_sdk::sys;
#[app::logic]
impl MyApp {
pub fn create_manual_proposal(&mut self, actions: Vec<ProposalAction>) -> ProposalId {
// Serialize actions to Borsh
let actions_data = borsh::to_vec(&actions)
.expect("Failed to serialize actions");
// Create buffer for result
let mut result_buffer = [0u8; 32];
// Send proposal using system function
unsafe {
sys::send_proposal(
calimero_sdk::sys::Buffer::from(&actions_data),
calimero_sdk::sys::BufferMut::new(&mut result_buffer)
)
}
ProposalId(result_buffer)
}
}
Approving Proposals
Basic Approval
Both approaches work identically:
use calimero_sdk::env::ext::{External, ProposalId};
#[app::logic]
impl MyApp {
// Using Self::external() (macro-generated)
pub fn approve_proposal_via_self(&mut self, proposal_id: ProposalId) {
Self::external().approve(proposal_id);
}
// Using External directly
pub fn approve_proposal_via_external(&mut self, proposal_id: ProposalId) {
External::approve(proposal_id);
}
}
Approval with Validation
#[app::logic]
impl MyApp {
pub fn approve_proposal_with_validation(&mut self, proposal_id: ProposalId) -> Result<(), String> {
// Check if user can approve
if !self.can_approve_proposals(&env::predecessor_account_id()) {
return Err("Insufficient permissions to approve proposals".to_string());
}
// Check if proposal exists and is not already approved
if let Some(proposal) = self.proposals.get(&proposal_id) {
if proposal.approved {
return Err("Proposal already approved".to_string());
}
// Mark as approved locally
if let Some(mut proposal) = self.proposals.get_mut(&proposal_id) {
proposal.approved = true;
proposal.approver = Some(env::predecessor_account_id());
}
// Approve externally - both approaches work
Self::external().approve(proposal_id);
// OR: External::approve(proposal_id);
Ok(())
} else {
Err("Proposal not found".to_string())
}
}
fn can_approve_proposals(&self, user: &str) -> bool {
self.governance_members.contains(user)
}
}
Complete Examples
Governance DAO Example
This example implements a simple DAO where members can create treasury transfer
proposals and approve them; proposal data is stored locally while
creation/approval is executed via the external proposal system
(Self::external()
/ External
).
use calimero_sdk::{app, env, env::ext::{External, DraftProposal, ProposalAction, AccountId, ProposalId}};
use calimero_storage::collections::{UnorderedMap, UnorderedSet};
#[app::state]
#[derive(Default, BorshSerialize, BorshDeserialize)]
#[borsh(crate = "calimero_sdk::borsh")]
struct DAO {
members: UnorderedSet<String>,
proposals: UnorderedMap<ProposalId, LocalProposal>,
treasury_balance: u128,
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct LocalProposal {
pub id: ProposalId,
pub creator: String,
pub actions: Vec<ProposalAction>,
pub approvals: UnorderedSet<String>,
pub executed: bool,
pub created_at: u64,
}
#[app::logic]
impl DAO {
#[app::init]
pub fn init(initial_members: Vec<String>) -> Self {
let mut members = UnorderedSet::new();
for member in initial_members {
members.insert(member);
}
Self {
members,
proposals: UnorderedMap::new(),
treasury_balance: 0,
}
}
pub fn create_treasury_transfer_proposal(
&mut self,
receiver_id: String,
amount: u128,
) -> Result<ProposalId, String> {
if !self.members.contains(&env::predecessor_account_id()) {
return Err("Only members can create proposals".to_string());
}
if amount > self.treasury_balance {
return Err("Insufficient treasury balance".to_string());
}
// Create external proposal using Self::external() (macro-generated)
let proposal_id = Self::external()
.propose()
.transfer(AccountId(receiver_id.clone()), amount)
.send();
// Store proposal locally
let local_proposal = LocalProposal {
id: proposal_id,
creator: env::predecessor_account_id(),
actions: vec![ProposalAction::Transfer {
receiver_id: AccountId(receiver_id),
amount,
}],
approvals: UnorderedSet::new(),
executed: false,
created_at: env::block_timestamp(),
};
self.proposals.insert(proposal_id, local_proposal);
Ok(proposal_id)
}
pub fn approve_proposal(&mut self, proposal_id: ProposalId) -> Result<(), String> {
if !self.members.contains(&env::predecessor_account_id()) {
return Err("Only members can approve proposals".to_string());
}
let mut proposal = self.proposals.get_mut(&proposal_id)
.ok_or("Proposal not found")?;
if proposal.executed {
return Err("Proposal already executed".to_string());
}
let approver = env::predecessor_account_id();
if proposal.approvals.contains(&approver) {
return Err("Already approved this proposal".to_string());
}
// Add approval locally
proposal.approvals.insert(approver.clone());
// Approve externally - both approaches work identically
Self::external().approve(proposal_id);
// OR: External::approve(proposal_id);
Ok(())
}
pub fn get_proposal_status(&self, proposal_id: ProposalId) -> Option<ProposalStatus> {
self.proposals.get(&proposal_id).map(|proposal| {
if proposal.executed {
ProposalStatus::Executed
} else if proposal.approvals.len() >= 3 { // Assuming 3 approvals required
ProposalStatus::ReadyToExecute
} else {
ProposalStatus::PendingApproval {
current_approvals: proposal.approvals.len() as u32,
required: 3,
}
}
})
}
}
#[derive(BorshSerialize, BorshDeserialize)]
pub enum ProposalStatus {
PendingApproval { current_approvals: u32, required: u32 },
ReadyToExecute,
Executed,
}
Cross-Chain Bridge Example
This example outlines a governance-controlled bridge flow: a local bridge proposal is created and persisted, while an external proposal triggers an on-chain bridge contract (initiate_bridge); approvals are tracked locally, and in production you would map and approve the corresponding external ProposalId to execute the bridge.
use calimero_sdk::{app, env, env::ext::{External, DraftProposal, ProposalAction, AccountId}};
#[app::state]
#[derive(Default, BorshSerialize, BorshDeserialize)]
#[borsh(crate = "calimero_sdk::borsh")]
struct CrossChainBridge {
bridge_operators: UnorderedSet<String>,
pending_bridges: UnorderedMap<u64, BridgeProposal>,
bridge_counter: u64,
}
#[derive(BorshSerialize, BorshDeserialize)]
pub struct BridgeProposal {
pub id: u64,
pub source_chain: String,
pub target_chain: String,
pub amount: u128,
pub recipient: String,
pub status: BridgeStatus,
}
#[derive(BorshSerialize, BorshDeserialize)]
pub enum BridgeStatus {
Pending,
Approved,
Executed,
Failed,
}
#[app::logic]
impl CrossChainBridge {
pub fn create_bridge_proposal(
&mut self,
source_chain: String,
target_chain: String,
amount: u128,
recipient: String,
) -> Result<u64, String> {
let proposal_id = self.bridge_counter;
self.bridge_counter += 1;
let bridge_proposal = BridgeProposal {
id: proposal_id,
source_chain,
target_chain,
amount,
recipient,
status: BridgeStatus::Pending,
};
self.pending_bridges.insert(proposal_id, bridge_proposal);
// Create external proposal for the bridge operation
// Both approaches work identically
let _external_proposal_id = Self::external()
.propose()
.external_function_call(
"bridge.near".to_string(),
"initiate_bridge".to_string(),
serde_json::to_string(&bridge_proposal).unwrap(),
0,
)
.send();
Ok(proposal_id)
}
pub fn approve_bridge(&mut self, proposal_id: u64) -> Result<(), String> {
if !self.bridge_operators.contains(&env::predecessor_account_id()) {
return Err("Only bridge operators can approve bridges".to_string());
}
let mut bridge = self.pending_bridges.get_mut(&proposal_id)
.ok_or("Bridge proposal not found")?;
if bridge.status != BridgeStatus::Pending {
return Err("Bridge is not in pending status".to_string());
}
bridge.status = BridgeStatus::Approved;
// Note: In a real implementation, you would need to map the local proposal ID
// to the external proposal ID for approval
// For now, we'll just update the local status
Ok(())
}
}
Best Practices
1. Always Validate Proposals
pub fn create_proposal(&mut self, actions: Vec<ProposalAction>) -> Result<ProposalId, String> {
// Validate proposal parameters
self.validate_proposal_actions(&actions)?;
// Check permissions
if !self.can_create_proposals(&env::predecessor_account_id()) {
return Err("Insufficient permissions".to_string());
}
// Create proposal - both approaches work
let proposal_id = Self::external().propose();
// OR: let proposal_id = External::propose();
// Add actions one by one with validation
for action in actions {
proposal_id = self.add_validated_action(proposal_id, action)?;
}
Ok(proposal_id.send())
}
fn validate_proposal_actions(&self, actions: &[ProposalAction]) -> Result<(), String> {
for action in actions {
match action {
ProposalAction::ExternalFunctionCall { deposit, .. } => {
if *deposit > self.max_proposal_deposit {
return Err("Deposit too high".to_string());
}
}
ProposalAction::Transfer { amount, .. } => {
if *amount > self.max_transfer_amount {
return Err("Transfer amount too high".to_string());
}
}
_ => {}
}
}
Ok(())
}
2. Use Events for Tracking
#[app::event]
pub enum GovernanceEvent<'a> {
ProposalCreated { id: &'a ProposalId, creator: &'a str },
ProposalApproved { id: &'a ProposalId, approver: &'a str },
ProposalExecuted { id: &'a ProposalId },
}
#[app::logic]
impl GovernanceApp {
pub fn approve_proposal(&mut self, proposal_id: ProposalId) -> Result<(), String> {
// ... approval logic ...
app::emit!(GovernanceEvent::ProposalApproved {
id: &proposal_id,
approver: &env::predecessor_account_id(),
});
Ok(())
}
}
3. Implement Proper Error Handling
pub fn approve_proposal(&mut self, proposal_id: ProposalId) -> Result<(), String> {
let proposal = self.proposals.get(&proposal_id)
.ok_or("Proposal not found")?;
if proposal.executed {
return Err("Proposal already executed".to_string());
}
if !self.can_approve_proposals(&env::predecessor_account_id()) {
return Err("Insufficient permissions".to_string());
}
// Approve with error handling - both approaches work
Self::external().approve(proposal_id);
// OR: External::approve(proposal_id);
// Update local state
if let Some(mut proposal) = self.proposals.get_mut(&proposal_id) {
proposal.approvals.insert(env::predecessor_account_id());
}
Ok(())
}
4. Use Batch Operations for Efficiency
pub fn approve_multiple_proposals(&mut self, proposal_ids: Vec<ProposalId>) -> Result<(), String> {
for proposal_id in proposal_ids {
self.approve_proposal(proposal_id)?;
}
Ok(())
}
Important Notes
✅ Both APIs Are Valid and Equivalent
Self::external()
- Generated automatically by#[app::state]
macroExternal::
- Direct usage of the External struct- Both work identically - Choose the style that fits your codebase
🔄 How It Actually Works
- Macro Generation:
#[app::state]
automatically generatesSelf::external()
method - Proposal Creation: Use either
Self::external().propose()
orExternal::propose()
(they are equivalent -Self::external()
returns anExternal
instance) - Action Building: Use the
DraftProposal
builder methods to add actions - Proposal Submission: Call
.send()
to submit the proposal and get aProposalId
- Approval: Use either
Self::external().approve()
orExternal::approve()
- External Execution: Proposals are executed by the external proxy contract system
📚 Next Steps
This guide covers the essential aspects of Calimero's external proposal system. For more advanced topics, explore:
Remember to always test your governance systems thoroughly and implement proper security measures for production deployments.