Campaign state & init
Model crowdfunding campaigns in near-sdk 5: a Campaign struct, store collections with prefixes, #[init], and create_campaign charging its creator for storage.
Beginner16 min read3-question check
Time to replace the template. In this milestone the contract learns to store campaigns: a struct per campaign, a collection that holds them, an #[init] constructor, and a create_campaign method. It already decides the most NEAR-specific question of all: who pays for the bytes a campaign takes up?
The data model#
struct Campaign {
address creator;
string title;
uint256 goal;
uint256 raised;
uint64 deadline;
address token; // address(0) = ETH
bool claimed;
uint32 backerCount;
}
Campaign[] public campaigns; // id = index
mapping(uint256 => mapping(address => uint256)) public pledges;
mapping(uint256 => mapping(uint32 => address)) public backers;#[near(serializers = [borsh])]
pub struct Campaign {
pub creator: AccountId,
pub title: String,
pub goal: u128,
pub raised: u128,
pub deadline_ms: u64,
pub token: Option<AccountId>, // None = NEAR
pub claimed: bool,
pub backer_count: u32,
}
#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Crowdfund {
campaigns: Vector<Campaign>, // id = index
pledges: LookupMap<(CampaignId, AccountId), u128>,
backers: LookupMap<(CampaignId, u32), AccountId>,
}Nested mappings become one flat map with a tuple key: (campaign_id, account) instead of pledges[id][account]. Each entry is its own storage record, read and written only when touched.
backers is a per-campaign list emulated with (campaign_id, index) keys, so we can page through a campaign’s backers later without nesting collections inside collections.
Why these collections? Campaigns are append-only and identified by a sequential id, so a Vector is the natural fit: the id is the index and paging is a range of indexes. Pledges and backer slots are only ever looked up by key, so LookupMap (no iteration overhead) is enough. Both come from near_sdk::store, which caches reads and buffers writes until the end of the call.
The full contract for this milestone#
use near_sdk::borsh::BorshSerialize;
use near_sdk::json_types::U128;
use near_sdk::store::{LookupMap, Vector};
use near_sdk::{env, near, require, AccountId, BorshStorageKey, NearToken, PanicOnDefault, Promise};
pub type CampaignId = u32;
const MAX_TITLE_LEN: usize = 100;
const MAX_DURATION_MS: u64 = 90 * 24 * 60 * 60 * 1000; // 90 days
#[derive(BorshSerialize, BorshStorageKey)]
#[borsh(crate = "near_sdk::borsh")]
enum StorageKey {
Campaigns,
Pledges,
Backers,
}
/// Stored on-chain (Borsh). Amounts are plain u128 in yoctoNEAR or token units.
#[near(serializers = [borsh])]
pub struct Campaign {
pub creator: AccountId,
pub title: String,
pub goal: u128,
pub raised: u128,
pub deadline_ms: u64,
pub token: Option<AccountId>, // None = NEAR; tokens arrive in lesson 6
pub claimed: bool,
pub backer_count: u32,
}
/// Returned to clients (JSON). u128 becomes a string via U128.
#[near(serializers = [json])]
pub struct CampaignView {
pub id: CampaignId,
pub creator: AccountId,
pub title: String,
pub goal: U128,
pub raised: U128,
pub deadline_ms: u64,
pub token: Option<AccountId>,
pub claimed: bool,
pub backer_count: u32,
}
#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Crowdfund {
campaigns: Vector<Campaign>, // id = index
pledges: LookupMap<(CampaignId, AccountId), u128>, // used from lesson 3
backers: LookupMap<(CampaignId, u32), AccountId>, // used from lesson 3
}
#[near]
impl Crowdfund {
#[init]
pub fn new() -> Self {
Self {
campaigns: Vector::new(StorageKey::Campaigns),
pledges: LookupMap::new(StorageKey::Pledges),
backers: LookupMap::new(StorageKey::Backers),
}
}
#[payable]
pub fn create_campaign(
&mut self,
title: String,
goal: U128,
duration_ms: u64,
token: Option<AccountId>,
) -> CampaignId {
require!(!title.is_empty() && title.len() <= MAX_TITLE_LEN, "title must be 1-100 bytes");
require!(goal.0 > 0, "goal must be positive");
require!(duration_ms > 0 && duration_ms <= MAX_DURATION_MS, "duration out of range");
let creator = env::predecessor_account_id();
let deadline_ms = env::block_timestamp_ms() + duration_ms;
let initial = env::storage_usage();
let id = self.campaigns.len();
self.campaigns.push(Campaign {
creator: creator.clone(),
title,
goal: goal.0,
raised: 0,
deadline_ms,
token,
claimed: false,
backer_count: 0,
});
self.campaigns.flush(); // write the buffered push so storage_usage() sees it
let cost = storage_cost_since(initial);
refund_excess(&creator, cost);
id
}
pub fn get_campaign(&self, campaign_id: CampaignId) -> Option<CampaignView> {
self.campaigns.get(campaign_id).map(|c| view_of(campaign_id, c))
}
}
fn storage_cost_since(initial: u64) -> u128 {
let added = env::storage_usage().saturating_sub(initial);
env::storage_byte_cost().as_yoctonear() * added as u128
}
/// Charges cost from the attached deposit and sends the rest back.
fn refund_excess(payer: &AccountId, cost: u128) {
let attached = env::attached_deposit().as_yoctonear();
require!(attached >= cost, format!("attach at least {cost} yoctoNEAR for storage"));
let excess = attached - cost;
if excess > 0 {
Promise::new(payer.clone())
.transfer(NearToken::from_yoctonear(excess))
.detach();
}
}
fn view_of(id: CampaignId, c: &Campaign) -> CampaignView {
CampaignView {
id,
creator: c.creator.clone(),
title: c.title.clone(),
goal: U128(c.goal),
raised: U128(c.raised),
deadline_ms: c.deadline_ms,
token: c.token.clone(),
claimed: c.claimed,
backer_count: c.backer_count,
}
}Why it works on NEAR#
- Storage prefixes. Every collection writes its entries under a unique byte prefix. The
StorageKeyenum hands out one prefix per collection (Borsh encodes the variants as 0, 1, 2), socampaigns,pledgesandbackerscan never overwrite each other. Never reuse or reorder these variants once deployed. - `#[derive(PanicOnDefault)]` + `#[init]`. The contract has no usable default state, so any call before
newpanics instead of silently running on an empty struct.#[init]methods also refuse to run twice. - `#[payable]`. Methods reject attached NEAR unless marked payable.
create_campaignneeds a deposit because the creator pays for the campaign’s bytes. - Measure, charge, refund. We read
env::storage_usage()before the write, flush the vector, read it again, and chargebytes × env::storage_byte_cost(). Whatever the creator over-attached goes back with a detached transfer. - Two structs.
Campaignis stored as compact Borsh with rawu128.CampaignViewis the JSON shape returned to clients, withU128so large amounts travel as strings (more on this in lesson 8).
MoreWhy .detach() on the refund?
A Promise you create but do not return still gets scheduled. Recent near-sdk versions ask you to say so explicitly: .detach() marks it as a fire-and-forget promise whose result nobody waits for. That is fine for returning an excess deposit to the account that just sent it.
Check yourself
3 questions · progress saved in this browser