Near Learn

Going to mainnet & upgrades

Ship the crowdfunding dApp to NEAR mainnet: a pre-launch audit, reproducible builds with NEP-330 metadata, upgrades with state migration, and the full contract.

Intermediate43 min read3-question check

The contract works on testnet. Mainnet holds real money, so the last milestone is about trust: reviewing what can go wrong, proving the deployed code is the code you published, and deciding who may change it later — and how to change it without breaking the state that is already there.

What to audit before launch#

AreaWhat to check in this contractDeeper dive
StorageEvery write that grows state is paid by the caller (create_campaign, pledge, register_backer); inputs are capped (MAX_TITLE_LEN)Storage attacks
Async payoutsState changes before each payout; every payout has a #[private] callback that restores state and cannot panicCallbacks & rollbacks
Interleavingclaimed and the zeroed pledge block double claims and double refunds while callbacks are pendingReentrancy across receipts
Token receiverft_on_transfer trusts sender_id only after checking the predecessor is the campaign’s tokenPredecessor vs signer
Token payoutsft_transfer carries exactly 1 yoctoNEAR and enough gasOne yocto
LimitsViews cap pages at MAX_PAGE; refunds are pulled, never loopedUnbounded iteration
Arithmeticoverflow-checks = true in the release profileOverflow & panics
KeysWho holds full-access keys to the contract account, and what they could doAccess keys risk
Review the contract against the patterns this course used

Verifiable deploys (NEP-330)#

Contracts built with cargo-near expose a contract_source_metadata view (NEP-330) with the crate version, the repository link from Cargo.toml, and — for reproducible builds — the build environment and command. With a reproducible build anyone can rebuild your source in the same Docker image and check the hash matches the deployed code.

Mainnet deploy with a reproducible build (needs Docker, a clean git tree, and the commit pushed)
Shell
# a mainnet sub-account for the contract, funded from your main account
near account create-account fund-myself crowdfund.yourname.near '5 NEAR' \
  autogenerate-new-keypair save-to-keychain \
  sign-as yourname.near network-config mainnet sign-with-keychain send

# build in the pinned Docker image, deploy, and init in one transaction
cargo near deploy build-reproducible-wasm crowdfund.yourname.near \
  with-init-call new json-args '{}' \
  prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' \
  network-config mainnet sign-with-keychain send

# anyone can now read the build metadata
near contract call-function as-read-only crowdfund.yourname.near contract_source_metadata \
  json-args '{}' network-config mainnet now

Upgrades: new code, same state#

Redeploying to crowdfund.yourname.near replaces the code and keeps every byte of state. If v2 only adds or changes methods, a plain redeploy is the whole upgrade. If v2 changes the root struct, the stored Borsh bytes no longer match it, and you must migrate in the same transaction as the deploy.

Say v2 adds an emergency paused flag that stops new campaigns. The root struct gains a field, so v2 ships a migrate method that reads the old layout and writes the new one:

v2: decode the v1 root struct, add the new field, keep the collections
Rust
/// The v1 layout, kept only to decode the state that is already on-chain.
#[near(serializers = [borsh])]
pub struct CrowdfundV1 {
    campaigns: Vector<Campaign>,
    pledges: LookupMap<(CampaignId, AccountId), u128>,
    backers: LookupMap<(CampaignId, u32), AccountId>,
}

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Crowdfund {
    campaigns: Vector<Campaign>,
    pledges: LookupMap<(CampaignId, AccountId), u128>,
    backers: LookupMap<(CampaignId, u32), AccountId>,
    paused: bool, // new in v2: stops new campaigns
}

#[near]
impl Crowdfund {
    // new() also sets paused: false

    #[private]            // only the contract account itself
    #[init(ignore_state)] // allowed to run although state exists
    pub fn migrate() -> Self {
        let old: CrowdfundV1 = env::state_read()
            .unwrap_or_else(|| env::panic_str("no v1 state to migrate"));
        Self {
            campaigns: old.campaigns, // same prefixes, so the data carries over
            pledges: old.pledges,
            backers: old.backers,
            paused: false,
        }
    }

    #[private]
    pub fn set_paused(&mut self, paused: bool) {
        self.paused = paused;
    }

    // and in create_campaign: require!(!self.paused, "paused");
}
Deploy v2 and migrate atomically
Shell
cargo near deploy build-reproducible-wasm crowdfund.yourname.near \
  with-init-call migrate json-args '{}' \
  prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' \
  network-config mainnet sign-with-keychain send
  • Only the root struct needs migrating. The collections store their entries under their own prefixes; the root struct only holds their prefix and length. Keep the StorageKey variants in the same order and the data carries over untouched.
  • Changing `Campaign` is harder. Every stored campaign is Borsh in the old layout. Rather than rewrite them all, put new per-campaign data in a new map keyed by CampaignId.
  • Test the migration in near-workspaces: deploy v1, create campaigns and pledges, deploy v2 with migrate, and assert every view returns the same data.
SpoilerThe complete contract (src/lib.rs)

Everything from lessons 2–8 in one file — the version the sandbox tests in lesson 9 run against.

Rust
use near_sdk::borsh::BorshSerialize;
use near_sdk::json_types::U128;
use near_sdk::store::{LookupMap, Vector};
use near_sdk::{
    env, ext_contract, near, require, AccountId, BorshStorageKey, Gas, NearToken, PanicOnDefault,
    Promise, PromiseError, PromiseOrValue,
};

pub type CampaignId = u32;

const MAX_TITLE_LEN: usize = 100;
const MAX_DURATION_MS: u64 = 90 * 24 * 60 * 60 * 1000; // 90 days
const MAX_PAGE: u32 = 50;
const GAS_FT_TRANSFER: Gas = Gas::from_tgas(10);
const GAS_CALLBACK: Gas = Gas::from_tgas(10);
const ONE_YOCTO: NearToken = NearToken::from_yoctonear(1);

#[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, Some(ft) = a NEP-141 token
    pub claimed: bool,
    pub backer_count: u32,
}

#[near(serializers = [json])]
#[derive(PartialEq, Debug)]
pub enum CampaignStatus {
    Open,
    Succeeded,
    Failed,
}

/// 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,
    pub status: CampaignStatus,
}

#[near(serializers = [json])]
pub struct BackerView {
    pub account_id: AccountId,
    pub amount: U128,
}

#[near(event_json(standard = "crowdfund"))]
pub enum CrowdfundEvent<'a> {
    #[event_version("1.0.0")]
    CampaignCreated { campaign_id: CampaignId, creator: &'a AccountId, goal: U128, deadline_ms: u64 },
    #[event_version("1.0.0")]
    Pledged { campaign_id: CampaignId, backer: &'a AccountId, amount: U128 },
    #[event_version("1.0.0")]
    Claimed { campaign_id: CampaignId, creator: &'a AccountId, amount: U128 },
    #[event_version("1.0.0")]
    Refunded { campaign_id: CampaignId, backer: &'a AccountId, amount: U128 },
}

#[ext_contract(ext_ft)]
pub trait FungibleToken {
    fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option<String>);
}

#[near(contract_state)]
#[derive(PanicOnDefault)]
pub struct Crowdfund {
    campaigns: Vector<Campaign>,                        // id = index
    pledges: LookupMap<(CampaignId, AccountId), u128>, // (campaign, backer) -> total pledged
    backers: LookupMap<(CampaignId, u32), AccountId>,  // (campaign, n) -> nth backer
}

#[near]
impl Crowdfund {
    #[init]
    pub fn new() -> Self {
        Self {
            campaigns: Vector::new(StorageKey::Campaigns),
            pledges: LookupMap::new(StorageKey::Pledges),
            backers: LookupMap::new(StorageKey::Backers),
        }
    }

    // ---------- campaigns ----------

    #[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);

        CrowdfundEvent::CampaignCreated { campaign_id: id, creator: &creator, goal, deadline_ms }.emit();
        id
    }

    // ---------- pledging ----------

    #[payable]
    pub fn pledge(&mut self, campaign_id: CampaignId) {
        let backer = env::predecessor_account_id();
        let campaign = self.campaign(campaign_id);
        require!(campaign.token.is_none(), "this campaign takes a fungible token: use ft_transfer_call");
        require!(env::block_timestamp_ms() < campaign.deadline_ms, "campaign has ended");

        let storage_cost = self.ensure_backer(campaign_id, &backer);
        let attached = env::attached_deposit().as_yoctonear();
        require!(
            attached > storage_cost,
            format!("attach more than {storage_cost} yoctoNEAR (storage) to pledge")
        );
        self.add_pledge(campaign_id, &backer, attached - storage_cost);
    }

    /// Pre-pay the storage for a backer record (needed before an FT pledge).
    #[payable]
    pub fn register_backer(&mut self, campaign_id: CampaignId) {
        let backer = env::predecessor_account_id();
        require!(
            env::block_timestamp_ms() < self.campaign(campaign_id).deadline_ms,
            "campaign has ended"
        );
        let cost = self.ensure_backer(campaign_id, &backer);
        refund_excess(&backer, cost);
    }

    /// NEP-141 receiver: msg is the campaign id. Returns the amount NOT used.
    pub fn ft_on_transfer(
        &mut self,
        sender_id: AccountId,
        amount: U128,
        msg: String,
    ) -> PromiseOrValue<U128> {
        let token = env::predecessor_account_id();
        let campaign_id: CampaignId = msg
            .parse()
            .unwrap_or_else(|_| env::panic_str("msg must be a campaign id"));
        let campaign = self.campaign(campaign_id);
        require!(campaign.token.as_ref() == Some(&token), "token not accepted by this campaign");

        let ended = env::block_timestamp_ms() >= campaign.deadline_ms;
        let registered = self.pledges.contains_key(&(campaign_id, sender_id.clone()));
        if ended || !registered {
            env::log_str("pledge not accepted: campaign ended or backer not registered");
            return PromiseOrValue::Value(amount); // every token goes back
        }

        self.add_pledge(campaign_id, &sender_id, amount.0);
        PromiseOrValue::Value(U128(0))
    }

    // ---------- payouts ----------

    pub fn claim(&mut self, campaign_id: CampaignId) -> Promise {
        let now = env::block_timestamp_ms();
        let campaign = self.campaign_mut(campaign_id);
        require!(env::predecessor_account_id() == campaign.creator, "only the creator can claim");
        require!(now >= campaign.deadline_ms, "campaign is still running");
        require!(campaign.raised >= campaign.goal, "goal not reached");
        require!(!campaign.claimed, "already claimed");

        campaign.claimed = true; // effects before the interaction
        let payout = pay(&campaign.token, campaign.creator.clone(), campaign.raised);

        payout.then(
            Self::ext(env::current_account_id())
                .with_static_gas(GAS_CALLBACK)
                .on_claim(campaign_id),
        )
    }

    pub fn refund(&mut self, campaign_id: CampaignId) -> Promise {
        let backer = env::predecessor_account_id();
        let campaign = self.campaign(campaign_id);
        require!(env::block_timestamp_ms() >= campaign.deadline_ms, "campaign is still running");
        require!(campaign.raised < campaign.goal, "goal was reached: no refunds");
        let token = campaign.token.clone();

        let key = (campaign_id, backer.clone());
        let amount = self.pledges.get(&key).copied().unwrap_or(0);
        require!(amount > 0, "nothing to refund");
        self.pledges.insert(key, 0); // deduct first

        pay(&token, backer.clone(), amount).then(
            Self::ext(env::current_account_id())
                .with_static_gas(GAS_CALLBACK)
                .on_refund(campaign_id, backer, U128(amount)),
        )
    }

    #[private]
    pub fn on_claim(
        &mut self,
        campaign_id: CampaignId,
        #[callback_result] result: Result<(), PromiseError>,
    ) -> bool {
        let campaign = self.campaign_mut(campaign_id);
        if result.is_err() {
            campaign.claimed = false; // let the creator try again
            return false;
        }
        CrowdfundEvent::Claimed {
            campaign_id,
            creator: &campaign.creator,
            amount: U128(campaign.raised),
        }
        .emit();
        true
    }

    #[private]
    pub fn on_refund(
        &mut self,
        campaign_id: CampaignId,
        backer: AccountId,
        amount: U128,
        #[callback_result] result: Result<(), PromiseError>,
    ) -> bool {
        if result.is_err() {
            // restore by adding to the CURRENT value, never a cached one
            let key = (campaign_id, backer);
            let current = self.pledges.get(&key).copied().unwrap_or(0);
            self.pledges.insert(key, current + amount.0);
            return false;
        }
        CrowdfundEvent::Refunded { campaign_id, backer: &backer, amount }.emit();
        true
    }

    // ---------- views ----------

    pub fn get_campaign(&self, campaign_id: CampaignId) -> Option<CampaignView> {
        self.campaigns.get(campaign_id).map(|c| view_of(campaign_id, c))
    }

    pub fn get_campaigns(&self, from_index: Option<u32>, limit: Option<u32>) -> Vec<CampaignView> {
        let from = from_index.unwrap_or(0);
        let limit = limit.unwrap_or(20).min(MAX_PAGE);
        let end = from.saturating_add(limit).min(self.campaigns.len());
        (from..end)
            .filter_map(|id| self.campaigns.get(id).map(|c| view_of(id, c)))
            .collect()
    }

    pub fn get_backers(
        &self,
        campaign_id: CampaignId,
        from_index: Option<u32>,
        limit: Option<u32>,
    ) -> Vec<BackerView> {
        let count = self.campaigns.get(campaign_id).map(|c| c.backer_count).unwrap_or(0);
        let from = from_index.unwrap_or(0);
        let limit = limit.unwrap_or(20).min(MAX_PAGE);
        let end = from.saturating_add(limit).min(count);
        (from..end)
            .filter_map(|i| self.backers.get(&(campaign_id, i)))
            .map(|account_id| BackerView {
                account_id: account_id.clone(),
                amount: self.get_pledge(campaign_id, account_id.clone()),
            })
            .collect()
    }

    pub fn get_pledge(&self, campaign_id: CampaignId, account_id: AccountId) -> U128 {
        U128(self.pledges.get(&(campaign_id, account_id)).copied().unwrap_or(0))
    }

    pub fn get_campaign_count(&self) -> u32 {
        self.campaigns.len()
    }
}

// ---------- internal helpers: a plain impl block, so none of these are exported ----------

impl Crowdfund {
    fn campaign(&self, id: CampaignId) -> &Campaign {
        self.campaigns
            .get(id)
            .unwrap_or_else(|| env::panic_str("no such campaign"))
    }

    fn campaign_mut(&mut self, id: CampaignId) -> &mut Campaign {
        self.campaigns
            .get_mut(id)
            .unwrap_or_else(|| env::panic_str("no such campaign"))
    }

    /// Creates the backer's records if they are new. Returns their storage cost in yoctoNEAR.
    fn ensure_backer(&mut self, campaign_id: CampaignId, backer: &AccountId) -> u128 {
        let key = (campaign_id, backer.clone());
        if self.pledges.contains_key(&key) {
            return 0;
        }
        let initial = env::storage_usage();

        self.pledges.insert(key, 0);
        let campaign = self.campaign_mut(campaign_id);
        let index = campaign.backer_count;
        campaign.backer_count += 1;
        self.backers.insert((campaign_id, index), backer.clone());

        self.pledges.flush();
        self.backers.flush();
        storage_cost_since(initial)
    }

    fn add_pledge(&mut self, campaign_id: CampaignId, backer: &AccountId, amount: u128) {
        let key = (campaign_id, backer.clone());
        let total = self.pledges.get(&key).copied().unwrap_or(0) + amount;
        self.pledges.insert(key, total);
        self.campaign_mut(campaign_id).raised += amount;

        CrowdfundEvent::Pledged { campaign_id, backer, amount: U128(amount) }.emit();
    }
}

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();
    }
}

/// Sends amount of the campaign currency: native NEAR or a NEP-141 token.
fn pay(token: &Option<AccountId>, to: AccountId, amount: u128) -> Promise {
    match token {
        None => Promise::new(to).transfer(NearToken::from_yoctonear(amount)),
        Some(token) => ext_ft::ext(token.clone())
            .with_attached_deposit(ONE_YOCTO)
            .with_static_gas(GAS_FT_TRANSFER)
            .ft_transfer(to, U128(amount), None),
    }
}

fn view_of(id: CampaignId, c: &Campaign) -> CampaignView {
    let status = if env::block_timestamp_ms() < c.deadline_ms {
        CampaignStatus::Open
    } else if c.raised >= c.goal {
        CampaignStatus::Succeeded
    } else {
        CampaignStatus::Failed
    };
    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,
        status,
    }
}

Check yourself

3 questions · progress saved in this browser

  1. 1.v2 of the contract adds a paused: bool field to the root struct. What happens if you redeploy without a migration?
  2. 2.What does a reproducible build plus NEP-330 metadata let users do?
  3. 3.Why do the Vector and LookupMap contents survive the v1 → v2 migration without being copied?