Building an Escrow Contract (in Ink!)
Escrow is a financial arrangement where a neutral third party temporarily holds funds/assets while two parties complete a transaction. It ensures:
- Security: Funds are protected until conditions are met.
- Trust: Neither party can act unilaterally.
- Automation: Rules are enforced programmatically (via smart contracts).
Key Features
Feature | Description |
---|---|
Mutual Approval | Both buyer and seller must approve to release funds. |
Automatic Execution | Transfers funds instantly when both parties approve. |
Cancellation | Allows refunds (when funded) if the transaction fails. |
Transparency | All actions logged as on-chain events |
Security Checks | Prevents invalid states. |
Data Structure
Component | Type | Description |
---|---|---|
buyer | AccountId | Initiator/asset depositor |
seller | AccountId | Recipient of assets |
amount | Balance | Agreed transaction value |
buyer_approved | bool | Buyer’s confirmation flag |
seller_approved | bool | Seller’s confirmation flag |
state | EscrowState | Current lifecycle stage (see state diagram) |
Functions overview
initiate_escrow
- Start Transaction
Key Points:
- Buyer initiates by specifying seller/amount
- Prevents self-dealing with
buyer == seller
check - Auto-increments escrow IDs
deposit_assets
- Fund Escrow
Key Points:
- Only buyer can deposit
- Exact amount required
- Must be in
Created
state
complete_escrow
- Mutual Approval
Key Points:
- Buyer and seller must call separately
- Prevents duplicate approvals
- Funds transfer only after mutual consent
cancel_escrow
- Abort Transaction
Key Points:
- Either party can cancel
- Refund only if funds were deposited
- Completed escrows cannot be canceled
States Diagram
Sequence Diagram
Full Implementation
lib.rs
#![cfg_attr(not(feature = "std"), no_std, no_main)]
#[ink::contract]
mod escrow_smart_contract {
use ink::storage::Mapping;
/// Unique identifier for escrow transactions
type EscrowId = u64;
/// Represents the possible states of an escrow transaction.
#[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))]
pub enum EscrowState {
/// The escrow has been created but no funds have been deposited.
Created = 0,
/// The buyer has deposited the agreed amount into the escrow.
Funded = 1,
/// Both parties have approved the transaction, and the funds have been transferred to the seller.
Completed = 2,
/// The escrow has been canceled, and the funds (if any) have been returned to the buyer.
Canceled = 3,
}
/// Represents the possible errors that can occur during escrow operations.
#[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
pub enum Error {
/// The caller is not authorized to perform the requested action.
Unauthorized = 0,
/// The escrow is in an invalid state for the requested operation.
InvalidState = 1,
/// The deposited amount is not equal to the agreed amount.
InvalidAmount = 2,
/// The party has already approved the transaction.
AlreadyApproved = 3,
/// The buyer and seller are the same account.
InvalidParticipants = 4,
/// The transfer of funds failed.
TransferFailed = 5,
/// The escrow with the given ID was not found.
NotFound = 6,
/// The escrow ID counter overflowed.
IdOverflow = 7,
}
/// The main contract struct that holds the escrow data.
#[ink(storage)]
pub struct EscrowSmartContract {
/// A mapping of escrow IDs to their corresponding escrow data.
escrows: Mapping<EscrowId, Escrow>,
/// The next available escrow ID.
next_id: EscrowId,
}
//----------------------------------
// Default Implementation
//----------------------------------
/// Provides default initialization values for the contract
impl Default for EscrowSmartContract {
fn default() -> Self {
Self {
next_id: 0,
escrows: Mapping::new(),
}
}
}
/// Represents the data of an escrow transaction.
#[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))]
pub struct Escrow {
/// The account ID of the buyer.
buyer: AccountId,
/// The account ID of the seller.
seller: AccountId,
/// The agreed amount to be transferred.
amount: Balance,
/// Whether the buyer has approved the transaction.
buyer_approved: bool,
/// Whether the seller has approved the transaction.
seller_approved: bool,
/// The current state of the escrow.
state: EscrowState,
}
/// Event emitted when a new escrow is initiated.
#[ink(event)]
pub struct Initiated {
/// The ID of the newly created escrow.
#[ink(topic)]
escrow_id: EscrowId,
/// The buyer's account ID.
buyer: AccountId,
/// The seller's account ID.
seller: AccountId,
/// The agreed amount.
amount: Balance,
}
/// Event emitted when funds are deposited into an escrow.
#[ink(event)]
pub struct Deposited {
/// The ID of the escrow.
#[ink(topic)]
escrow_id: EscrowId,
/// The deposited amount.
amount: Balance,
}
/// Event emitted when an escrow is completed.
#[ink(event)]
pub struct Completed {
/// The ID of the completed escrow.
#[ink(topic)]
escrow_id: EscrowId,
}
/// Event emitted when an escrow is canceled.
#[ink(event)]
pub struct Canceled {
/// The ID of the canceled escrow.
#[ink(topic)]
escrow_id: EscrowId,
}
impl EscrowSmartContract {
/// Constructor that initializes a new escrow contract.
#[ink(constructor)]
pub fn new() -> Self {
Self {
escrows: Mapping::default(),
next_id: 0,
}
}
/// Initiates a new escrow transaction.
///
/// # Arguments
///
/// * `seller` - The account ID of the seller.
/// * `amount` - The agreed amount to be transferred.
///
/// # Returns
///
/// * `Ok(EscrowId)` - The ID of the newly created escrow.
/// * `Err(Error)` - An error if the operation failed.
#[ink(message)]
pub fn initiate_escrow(
&mut self,
seller: AccountId,
amount: Balance
) -> Result<EscrowId, Error> {
// Get the caller's account ID (the buyer).
let buyer = self.env().caller();
// Check if the buyer and seller are the same account.
if buyer == seller {
return Err(Error::InvalidParticipants);
}
// Get the next available escrow ID.
let escrow_id = self.next_id;
// Increment the next ID, handling potential overflow.
self.next_id = escrow_id.checked_add(1).ok_or(Error::IdOverflow)?;
// Create the new escrow data.
let escrow = Escrow {
buyer,
seller,
amount,
buyer_approved: false,
seller_approved: false,
state: EscrowState::Created,
};
// Insert the escrow data into the storage mapping.
self.escrows.insert(escrow_id, &escrow);
// Emit an event to notify about the new escrow.
self.env().emit_event(Initiated {
escrow_id,
buyer,
seller,
amount,
});
// Return the new escrow ID.
Ok(escrow_id)
}
/// Deposits funds into an escrow.
///
/// # Arguments
///
/// * `escrow_id` - The ID of the escrow.
///
/// # Returns
///
/// * `Ok(())` - If the deposit was successful.
/// * `Err(Error)` - An error if the operation failed.
#[ink(message, payable)]
pub fn deposit_assets(&mut self, escrow_id: EscrowId) -> Result<(), Error> {
// Get a mutable reference to the escrow data.
let mut escrow = self.escrows.get(escrow_id).ok_or(Error::NotFound)?;
// Get the caller's account ID.
let caller = self.env().caller();
// Check if the caller is the buyer.
if caller != escrow.buyer {
return Err(Error::Unauthorized);
}
// Check if the escrow is in the correct state.
if escrow.state != EscrowState::Created {
return Err(Error::InvalidState);
}
// Check if the deposited amount is correct.
if self.env().transferred_value() != escrow.amount {
return Err(Error::InvalidAmount);
}
// Update the escrow state.
escrow.state = EscrowState::Funded;
// Save changes back to storage
self.escrows.insert(escrow_id, &escrow);
// Emit an event to notify about the deposit.
self.env().emit_event(Deposited {
escrow_id,
amount: escrow.amount,
});
Ok(())
}
/// Completes an escrow transaction if both parties have approved.
///
/// # Arguments
///
/// * `escrow_id` - The ID of the escrow.
///
/// # Returns
///
/// * `Ok(())` - If the escrow was successfully completed.
/// * `Err(Error)` - An error if the operation failed.
#[ink(message)]
pub fn complete_escrow(&mut self, escrow_id: EscrowId) -> Result<(), Error> {
// Get owned Escrow value
let mut escrow = self.escrows.get(escrow_id).ok_or(Error::NotFound)?;
// Check if the escrow is in the correct state.
if escrow.state != EscrowState::Funded {
return Err(Error::InvalidState);
}
// Pass owned value to approve function and get updated escrow
escrow = self.approve(escrow, self.env().caller())?;
// Save changes back to storage
self.escrows.insert(escrow_id, &escrow);
// Check if both parties have approved.
if escrow.buyer_approved && escrow.seller_approved {
// Transfer the funds to the seller.
self
.env()
.transfer(escrow.seller, escrow.amount)
.map_err(|_| Error::TransferFailed)?;
// Update the escrow state.
escrow.state = EscrowState::Completed;
// Save changes back to storage
self.escrows.insert(escrow_id, &escrow);
// Emit an event to notify about the completion.
self.env().emit_event(Completed { escrow_id });
}
Ok(())
}
/// Cancels an escrow transaction and refunds the buyer if funded.
///
/// # Arguments
///
/// * `escrow_id` - The ID of the escrow.
///
/// # Returns
///
/// * `Ok(())` - If the escrow was successfully canceled.
/// * `Err(Error)` - An error if the operation failed.
#[ink(message)]
pub fn cancel_escrow(&mut self, escrow_id: EscrowId) -> Result<(), Error> {
// Get a mutable reference to the escrow data.
let mut escrow = self.escrows.get(escrow_id).ok_or(Error::NotFound)?;
// Get the caller's account ID.
let caller = self.env().caller();
// Check if the caller is the buyer or the seller.
if caller != escrow.buyer && caller != escrow.seller {
return Err(Error::Unauthorized);
}
// Check if the escrow is already completed.
if escrow.state == EscrowState::Completed {
return Err(Error::InvalidState);
}
// Refund buyer if escrow was funded
if escrow.state == EscrowState::Funded {
self
.env()
.transfer(escrow.buyer, escrow.amount)
.map_err(|_| Error::TransferFailed)?;
}
// Update the escrow state.
escrow.state = EscrowState::Canceled;
// Save the modified escrow back to storage
self.escrows.insert(escrow_id, &escrow);
// Emit an event to notify about the cancellation.
self.env().emit_event(Canceled { escrow_id });
Ok(())
}
// --- Helper functions ---
/// Approves an escrow transaction for a given party.
///
/// # Arguments
///
/// * `escrow` - A mutable reference to the escrow data.
/// * `caller` - The account ID of the party approving.
///
/// # Returns
///
/// * `Ok(())` - If the approval was successful.
/// * `Err(Error)` - An error if the operation failed.
fn approve(&mut self, mut escrow: Escrow, caller: AccountId) -> Result<Escrow, Error> {
// Match the caller to the buyer or seller.
match caller {
// If the caller is the buyer.
_ if caller == escrow.buyer => {
// Check if the buyer has already approved.
if escrow.buyer_approved {
return Err(Error::AlreadyApproved);
}
// Update the buyer's approval status.
escrow.buyer_approved = true;
}
// If the caller is the seller.
_ if caller == escrow.seller => {
// Check if the seller has already approved.
if escrow.seller_approved {
return Err(Error::AlreadyApproved);
}
// Update the seller's approval status.
escrow.seller_approved = true;
}
// If the caller is neither the buyer nor the seller.
_ => {
return Err(Error::Unauthorized);
}
}
Ok(escrow)
}
#[ink(message)]
pub fn get_escrow(&self, escrow_id: EscrowId) -> Option<Escrow> {
self.escrows.get(escrow_id)
}
}
//----------------------------------
// Tests here
//----------------------------------
}
Cargo.toml
[package]
name = "escrow_smart_contract"
version = "0.1.0"
authors = ["[your_name] <[your_email]>"]
edition = "2021"
[dependencies]
ink = { version = "5.1.1", default-features = false }
scale = { package = "parity-scale-codec", version = "3.7.4", default-features = false, features = ["derive"] }
scale-info = { version = "2.11.6", default-features = false, features = ["derive"], optional = true }
[dev-dependencies]
ink_e2e = { version = "5.1.1" }
[lib]
path = "lib.rs"
[features]
default = ["std"]
std = [
"ink/std",
"scale/std",
"scale-info/std",
]
ink-as-dependency = []
e2e-tests = []
Test Scenarios
#[cfg(test)]
mod tests {
use super::*;
/// # Escrow Contract Test Suite
///
/// This module contains comprehensive tests for the EscrowSmartContract,
/// covering core functionality, edge cases, and security requirements.
///
/// ## Test Accounts Convention:
/// - Alice: Default caller/buyer (ink! default account)
/// - Bob: Seller account
/// - Charlie: Unauthorized third party
#[ink::test]
fn test_initiate_escrow() {
// Arrange: Initialize contract and test accounts
let accounts = ink::env::test::default_accounts::<ink::env::DefaultEnvironment>();
let mut contract = EscrowSmartContract::new();
// Act & Assert: Test successful escrow creation
let amount = 100;
let result = contract.initiate_escrow(accounts.bob, amount);
assert!(result.is_ok(), "Should successfully create escrow");
let escrow_id = result.unwrap();
// Verify stored escrow details
let escrow = contract.escrows
.get(escrow_id)
.expect("Escrow should exist after creation");
assert_eq!(escrow.buyer, accounts.alice, "Buyer should be caller");
assert_eq!(escrow.seller, accounts.bob, "Seller should match argument");
assert_eq!(escrow.amount, amount, "Amount should match initial value");
assert_eq!(escrow.state, EscrowState::Created, "Initial state should be Created");
// Test validation: Buyer cannot be seller
let invalid_result = contract.initiate_escrow(accounts.alice, amount);
assert_eq!(
invalid_result,
Err(Error::InvalidParticipants),
"Should prevent buyer=seller creation"
);
}
#[ink::test]
fn test_deposit_assets() {
// Arrange: Create escrow and set test environment
let accounts = ink::env::test::default_accounts::<ink::env::DefaultEnvironment>();
let mut contract = EscrowSmartContract::new();
let amount = 100;
let escrow_id = contract.initiate_escrow(accounts.bob, amount).unwrap();
// Test successful deposit
ink::env::test::set_value_transferred::<ink::env::DefaultEnvironment>(amount);
let deposit_result = contract.deposit_assets(escrow_id);
assert!(deposit_result.is_ok(), "Should accept valid deposit");
// Verify state transition
let escrow = contract.escrows
.get(escrow_id)
.expect("Escrow should exist after deposit");
assert_eq!(
escrow.state,
EscrowState::Funded,
"State should transition to Funded after deposit"
);
// Test invalid amount scenario
ink::env::test::set_value_transferred::<ink::env::DefaultEnvironment>(amount + 1);
let invalid_deposit_result = contract.deposit_assets(escrow_id);
assert_eq!(
invalid_deposit_result,
Err(Error::InvalidState),
"Should reject deposit with incorrect amount"
);
}
#[ink::test]
fn test_complete_escrow() {
// Arrange: Set up funded escrow
let accounts = ink::env::test::default_accounts::<ink::env::DefaultEnvironment>();
let mut contract = EscrowSmartContract::new();
let amount = 100;
// Initialize and fund escrow as buyer
ink::env::test::set_caller::<ink::env::DefaultEnvironment>(accounts.alice);
let escrow_id = contract.initiate_escrow(accounts.bob, amount).unwrap();
ink::env::test::set_value_transferred::<ink::env::DefaultEnvironment>(amount);
contract.deposit_assets(escrow_id).unwrap();
// Act & Assert: Buyer approval
let buyer_approval_result = contract.complete_escrow(escrow_id);
assert!(buyer_approval_result.is_ok(), "Buyer should approve successfully");
// Verify partial approval state
let escrow = contract.escrows
.get(escrow_id)
.expect("Escrow should exist after approval");
assert!(escrow.buyer_approved, "Buyer approval flag should be set");
assert!(!escrow.seller_approved, "Seller should not be approved yet");
assert_eq!(
escrow.state,
EscrowState::Funded,
"State should remain Funded until both approve"
);
// Seller approval
ink::env::test::set_caller::<ink::env::DefaultEnvironment>(accounts.bob);
let seller_approval_result = contract.complete_escrow(escrow_id);
assert!(seller_approval_result.is_ok(), "Seller should approve successfully");
// Verify final state
let final_escrow = contract.escrows
.get(escrow_id)
.expect("Escrow should exist after completion");
assert!(final_escrow.buyer_approved, "Buyer approval should persist");
assert!(final_escrow.seller_approved, "Seller approval should be set");
assert_eq!(
final_escrow.state,
EscrowState::Completed,
"State should transition to Completed after dual approval"
);
}
#[ink::test]
fn test_cancel_escrow_by_buyer() {
// Arrange: Create and fund escrow
let accounts = ink::env::test::default_accounts::<ink::env::DefaultEnvironment>();
let mut contract = EscrowSmartContract::new();
let amount = 100;
let escrow_id = contract.initiate_escrow(accounts.bob, amount).unwrap();
// Fund escrow
ink::env::test::set_value_transferred::<ink::env::DefaultEnvironment>(amount);
contract.deposit_assets(escrow_id).unwrap();
// Act: Cancel by buyer
let cancel_result = contract.cancel_escrow(escrow_id);
assert!(cancel_result.is_ok(), "Buyer should cancel successfully");
// Assert: State transition and funds handling
let escrow = contract.escrows
.get(escrow_id)
.expect("Escrow should exist after cancellation");
assert_eq!(escrow.state, EscrowState::Canceled, "State should transition to Canceled");
}
#[ink::test]
fn test_cancel_escrow_completed() {
// Arrange: Create completed escrow
let accounts = ink::env::test::default_accounts::<ink::env::DefaultEnvironment>();
let mut contract = EscrowSmartContract::new();
let amount = 100;
let escrow_id = contract.initiate_escrow(accounts.bob, amount).unwrap();
// Complete escrow lifecycle
ink::env::test::set_value_transferred::<ink::env::DefaultEnvironment>(amount);
contract.deposit_assets(escrow_id).unwrap();
contract.complete_escrow(escrow_id).unwrap(); // Buyer approval
ink::env::test::set_caller::<ink::env::DefaultEnvironment>(accounts.bob);
contract.complete_escrow(escrow_id).unwrap(); // Seller approval
// Act & Assert: Cancel completed escrow
ink::env::test::set_caller::<ink::env::DefaultEnvironment>(accounts.alice);
let cancel_result = contract.cancel_escrow(escrow_id);
assert_eq!(
cancel_result,
Err(Error::InvalidState),
"Should prevent canceling completed escrow"
);
// Verify state persistence
let escrow = contract.escrows.get(escrow_id).expect("Escrow should still exist");
assert_eq!(escrow.state, EscrowState::Completed, "State should remain Completed");
}
#[ink::test]
fn test_unauthorized_actions() {
// Arrange: Create escrow and set up unauthorized caller
let accounts = ink::env::test::default_accounts::<ink::env::DefaultEnvironment>();
let mut contract = EscrowSmartContract::new();
let amount = 100;
let escrow_id = contract.initiate_escrow(accounts.bob, amount).unwrap();
// Set unauthorized caller (Charlie)
ink::env::test::set_caller::<ink::env::DefaultEnvironment>(accounts.charlie);
// Test 1: Unauthorized deposit attempt
ink::env::test::set_value_transferred::<ink::env::DefaultEnvironment>(amount);
let deposit_result = contract.deposit_assets(escrow_id);
assert_eq!(
deposit_result,
Err(Error::Unauthorized),
"Should prevent unauthorized deposits"
);
// Test 2: Unauthorized completion attempt
let complete_result = contract.complete_escrow(escrow_id);
assert_eq!(
complete_result,
Err(Error::InvalidState),
"Should prevent unauthorized completion"
);
// Test 3: Unauthorized cancellation attempt
let cancel_result = contract.cancel_escrow(escrow_id);
assert_eq!(
cancel_result,
Err(Error::Unauthorized),
"Should prevent unauthorized cancellation"
);
}
}