Adding a New Contract
Create and integrate new ink! smart contracts in your project
Overview
This guide walks you through creating a new ink! smart contract and integrating it with your inkathon frontend. We'll build a simple token contract as an example.
Contract Development Flow
Create Contract Directory
Create a new directory for your contract:
# From contracts/src directory
mkdir contracts/src/token
cd contracts/src/token
Write the Contract
Create lib.rs
with your contract code:
#![cfg_attr(not(feature = "std"), no_std, no_main)]
#[ink::contract]
mod token {
use ink::storage::Mapping;
#[ink(storage)]
pub struct Token {
total_supply: Balance,
balances: Mapping<AccountId, Balance>,
owner: AccountId,
}
#[ink(event)]
pub struct Transfer {
from: Option<AccountId>,
to: AccountId,
value: Balance,
}
#[derive(Debug, PartialEq, Eq)]
#[ink::scale_derive(Encode, Decode, TypeInfo)]
pub enum Error {
InsufficientBalance,
}
pub type Result<T> = core::result::Result<T, Error>;
impl Token {
#[ink(constructor)]
pub fn new(total_supply: Balance) -> Self {
let mut balances = Mapping::default();
let caller = Self::env().caller();
balances.insert(caller, &total_supply);
Self::env().emit_event(Transfer {
from: None,
to: caller,
value: total_supply,
});
Self {
total_supply,
balances,
owner: caller,
}
}
#[ink(message)]
pub fn total_supply(&self) -> Balance {
self.total_supply
}
#[ink(message)]
pub fn balance_of(&self, account: AccountId) -> Balance {
self.balances.get(account).unwrap_or(0)
}
#[ink(message)]
pub fn transfer(&mut self, to: AccountId, value: Balance) -> Result<()> {
let from = self.env().caller();
let from_balance = self.balance_of(from);
if from_balance < value {
return Err(Error::InsufficientBalance);
}
self.balances.insert(from, &(from_balance - value));
let to_balance = self.balance_of(to);
self.balances.insert(to, &(to_balance + value));
self.env().emit_event(Transfer {
from: Some(from),
to,
value,
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_works() {
let token = Token::new(1000);
assert_eq!(token.total_supply(), 1000);
}
#[test]
fn transfer_works() {
let mut token = Token::new(1000);
assert!(token.transfer(AccountId::from([0x01; 32]), 100).is_ok());
}
}
}
#![cfg_attr(not(feature = "std"), no_std, no_main)]
#[ink::contract]
mod token {
use ink::storage::Mapping;
#[ink(storage)]
pub struct Token {
name: String,
symbol: String,
decimals: u8,
total_supply: Balance,
balances: Mapping<AccountId, Balance>,
allowances: Mapping<(AccountId, AccountId), Balance>,
owner: AccountId,
}
// Events and implementation...
// (Extended version with approve, transferFrom, mint, burn)
}
Create Cargo.toml
Configure the contract build settings:
[package]
name = "token"
version = "0.1.0"
authors = ["Your Name"]
edition = "2021"
[dependencies]
ink = { version = "6.0.0-alpha", default-features = false }
[lib]
crate-type = ["cdylib"]
[features]
default = ["std"]
std = [
"ink/std",
]
ink-as-dependency = []
[profile.release]
overflow-checks = false
lto = true
opt-level = "z"
strip = true
Build the Contract
Build your new contract:
# From project root
bun run -F contracts build
# Verify the build
ls contracts/deployments/token/
# Should see: token.contract, token.json, token.polkavm
Generate TypeScript Types
Update PAPI types to include your new contract:
# From project root
bun run codegen
This reads the contract metadata and generates TypeScript interfaces.
Deploy the Contract
Deploy to your target network:
# Make sure ink-node is running
bun run node
# Deploy
CHAIN=dev bun run -F contracts deploy
# Set your account in .env.pop
echo 'ACCOUNT_URI="your seed phrase"' > contracts/.env.pop
# Deploy to Pop Network
CHAIN=pop bun run -F contracts deploy
The deployment script will:
- Instantiate the contract
- Export addresses to
deployments/token/<chain>.ts
Update Frontend Deployments
Add your contract to the frontend deployments:
// frontend/src/lib/inkathon/deployments.ts
// Import token deployments
import {
evmAddress as tokenEvmDev,
ss58Address as tokenSs58Dev
} from 'contracts/deployments/token/dev'
import {
evmAddress as tokenEvmPop,
ss58Address as tokenSs58Pop
} from 'contracts/deployments/token/pop'
export const contractDeployments = {
// ... existing contracts
token: {
dev: { evmAddress: tokenEvmDev, ss58Address: tokenSs58Dev },
pop: { evmAddress: tokenEvmPop, ss58Address: tokenSs58Pop },
},
}
Create Frontend Component
Create a component to interact with your contract:
// frontend/src/components/web3/token-card.tsx
'use client'
import { useState } from 'react'
import { contracts } from '@polkadot-api/descriptors'
import { useContract } from '@/lib/hooks/use-contract'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
export function TokenCard() {
const [recipient, setRecipient] = useState('')
const [amount, setAmount] = useState('')
const { contract, isLoading } = useContract('token')
const handleTransfer = async () => {
if (!contract) return
try {
await contract.tx.transfer(recipient, BigInt(amount))
// Handle success
} catch (error) {
// Handle error
}
}
const getBalance = async (account: string) => {
if (!contract) return
const balance = await contract.query.balanceOf(account)
return balance
}
return (
<div className="p-6 border rounded-lg">
<h3 className="text-lg font-semibold mb-4">Token Contract</h3>
<div className="space-y-4">
<Input
placeholder="Recipient address"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
<Input
type="number"
placeholder="Amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<Button
onClick={handleTransfer}
disabled={isLoading || !recipient || !amount}
>
Transfer
</Button>
</div>
</div>
)
}
Test Your Contract
Write integration tests for your contract:
// frontend/src/tests/token.test.ts
import { contracts } from '@polkadot-api/descriptors'
import { contractDeployments } from '@/lib/inkathon/deployments'
describe('Token Contract', () => {
it('should transfer tokens', async () => {
const address = contractDeployments.token.dev.ss58Address
// Test implementation
})
})
Project Structure After Adding Contract
Contract Best Practices
Storage Optimization
// Use Mapping for large collections
balances: Mapping<AccountId, Balance>,
// Pack structs efficiently
#[ink(storage)]
pub struct Packed {
// Group same-size fields
field1: u32,
field2: u32,
field3: bool,
field4: bool,
}
Error Handling
#[derive(Debug, PartialEq, Eq)]
#[ink::scale_derive(Encode, Decode, TypeInfo)]
pub enum Error {
InsufficientBalance,
Unauthorized,
InvalidInput,
}
// Use Result type for fallible operations
pub type Result<T> = core::result::Result<T, Error>;
Event Emission
#[ink(event)]
pub struct Transfer {
#[ink(topic)]
from: Option<AccountId>,
#[ink(topic)]
to: AccountId,
value: Balance,
}
// Emit events for important state changes
self.env().emit_event(Transfer { from, to, value });
Testing
#[cfg(test)]
mod tests {
use super::*;
use ink::env::test;
#[test]
fn constructor_works() {
let accounts = test::default_accounts::<Environment>();
test::set_caller::<Environment>(accounts.alice);
let token = Token::new(1000);
assert_eq!(token.balance_of(accounts.alice), 1000);
}
}
Common Patterns
Access Control
impl Token {
fn ensure_owner(&self) -> Result<()> {
if self.env().caller() != self.owner {
return Err(Error::Unauthorized);
}
Ok(())
}
#[ink(message)]
pub fn mint(&mut self, to: AccountId, amount: Balance) -> Result<()> {
self.ensure_owner()?;
// Mint logic
Ok(())
}
}
Upgradeable Contracts
#[ink(message)]
pub fn set_code(&mut self, code_hash: Hash) -> Result<()> {
self.ensure_owner()?;
self.env().set_code_hash(&code_hash)?;
Ok(())
}
Troubleshooting
Issue | Solution |
---|---|
Build fails | Check Rust version and wasm32 target installation |
Types not generated | Ensure contract builds successfully first |
Deployment fails | Verify account has funds and network is accessible |
Frontend can't find contract | Check deployment imports and exports |
Next Steps
- Implement more complex contract logic
- Add comprehensive test coverage
- Create UI components for all contract functions
- Set up automated testing in CI/CD
- Document your contract's API