Near Learn

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#

The same model on both chains
Solidity
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;
Rust
#[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>,
}
Coming from EVM

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#

src/lib.rs — replace the template contract with this
Rust
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 StorageKey enum hands out one prefix per collection (Borsh encodes the variants as 0, 1, 2), so campaigns, pledges and backers can 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 new panics instead of silently running on an empty struct. #[init] methods also refuse to run twice.
  • `#[payable]`. Methods reject attached NEAR unless marked payable. create_campaign needs 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 charge bytes × env::storage_byte_cost(). Whatever the creator over-attached goes back with a detached transfer.
  • Two structs. Campaign is stored as compact Borsh with raw u128. CampaignView is the JSON shape returned to clients, with U128 so 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

  1. 1.Why does create_campaign call self.campaigns.flush() before measuring storage?
  2. 2.What does the StorageKey enum protect against?
  3. 3.What happens if someone calls create_campaign before new has been called?