Skip to content

Checking access...

DOM Token V2

DOM (Decentralized Otter Money) is the utility token of the Hello World DAO, implemented as an ICRC-1/2 fungible token on the Internet Computer. Version 2 of the canister adds admin event logging, a USD-value burn path, and a structured donation burn record system.

Canister IDs:

  • Staging: njo7k-3qaaa-aaaau-aed4a-cai
  • Production: ybuho-zyaaa-aaaal-qwsfq-cai

Epic: BL-040 upgraded canister to State V2. BL-078.1 added donation burn records.


What Changed in V2

FeatureV1V2
Admin event logNoYes — all admin mutations recorded
Burn by USD valueNoYes — burn_usd_value field on BurnArgs
Donation burn recordsNoYes — structured DonationBurnRecord
Burn walletImplicitExplicit burn_wallet_principal config
required_approvalsHardcodedRuntime-configurable

The V2 state struct is backward-compatible. New fields use Option<T> to survive stable_restore from V1 WASM.


AdminEvent Logging

Every privileged mutation now emits an AdminEvent that is persisted in stable memory and queryable via Candid.

AdminEvent Types

rust
type AdminEventType = variant {
  BurnExecuted;
  BurnPolicyUpdated;
  MintExecuted;
  ThresholdUpdated;
  BurnWalletUpdated;
  ControllerAdded;
  ControllerRemoved;
  RequiredApprovalsUpdated;
  DonationBurnRecorded;
};

type AdminEvent = record {
  id         : nat64;
  timestamp  : nat64;        // IC time in nanoseconds
  actor      : principal;    // caller who triggered the event
  event_type : AdminEventType;
  details    : text;         // JSON-encoded payload (varies by type)
};

Querying Admin Events

rust
// Get all events (admin only)
list_admin_events : (ListAdminEventsArgs) -> (vec AdminEvent) query;

type ListAdminEventsArgs = record {
  from_id  : opt nat64;   // pagination cursor
  limit    : opt nat32;   // default 50, max 200
  filter   : opt AdminEventType;
};

Example: audit all burns since a given event ID:

bash
dfx canister call ybuho-zyaaa-aaaal-qwsfq-cai list_admin_events \
  '(record { from_id = opt 1500; limit = opt 100; filter = opt variant { BurnExecuted } })' \
  --network ic

Burn Paths

DOM tokens are designed to deflate over time. V2 adds a USD-value burn path to enable fiat-equivalent burn amounts, used primarily by the donation and marketplace flows.

Standard Burn (by token amount)

rust
type BurnArgs = record {
  amount       : nat;           // DOM tokens in e8s (8 decimal places)
  memo         : opt vec nat8;
  from_subaccount : opt vec nat8;
};

burn : (BurnArgs) -> (Result<nat64, BurnError>);

USD-Value Burn (V2, BL-075.3)

rust
type BurnUsdArgs = record {
  amount_usd      : float64;    // burn equivalent of this USD value
  slippage_bps    : nat16;      // max price deviation tolerance (basis points)
  memo            : opt vec nat8;
  from_subaccount : opt vec nat8;
};

burn_usd_value : (BurnUsdArgs) -> (Result<BurnUsdReceipt, BurnError>);

type BurnUsdReceipt = record {
  tx_id        : nat64;
  dom_burned   : nat;        // actual DOM tokens burned (e8s)
  price_used   : float64;    // DOM/USD price at execution time
  usd_value    : float64;    // confirmed USD equivalent
};

The canister reads the current DOM/USD price from the latest oracle tick stored by the governance canister (SetDomPrice proposals). If the price has moved beyond slippage_bps since the burn was initiated, BurnError::SlippageExceeded is returned.


Donation Burn Records (BL-078.1)

When a member donates to an Otter Camp campaign, the oracle-bridge coordinates a burn that converts part of the donation to DOM and burns it. This deflationary mechanism is recorded as a DonationBurnRecord.

DonationBurnRecord Struct

rust
type DonationBurnRecord = record {
  id             : nat64;
  campaign_id    : text;         // Otter Camp campaign identifier
  donor          : principal;
  donation_usd   : float64;      // total donation in USD
  dom_burned     : nat;          // DOM tokens burned (e8s)
  burn_price_usd : float64;      // DOM/USD price at burn time
  tx_id          : nat64;        // dom-token ledger transaction ID
  timestamp      : nat64;        // IC nanoseconds
  memo           : opt text;
};

Recording a Donation Burn

Donation burns are initiated by the oracle-bridge (which processes the fiat payment confirmation) and submitted to the canister via an authenticated inter-canister call or oracle-bridge signed request:

rust
record_donation_burn : (DonationBurnArgs) -> (Result<nat64, BurnError>);

type DonationBurnArgs = record {
  campaign_id    : text;
  donor          : principal;
  donation_usd   : float64;
  burn_pct_bps   : nat16;      // basis points of donation to burn, e.g. 1000 = 10%
  slippage_bps   : nat16;
  memo           : opt text;
};

Querying Donation Burns

rust
// Get donation burn history for a campaign
list_donation_burns_by_campaign : (campaign_id : text, limit : opt nat32) -> (vec DonationBurnRecord) query;

// Get donation burn history for a donor
list_donation_burns_by_donor : (principal, limit : opt nat32) -> (vec DonationBurnRecord) query;

// Get a single burn record
get_donation_burn : (id : nat64) -> (opt DonationBurnRecord) query;

Burn Wallet Architecture

The burn wallet is a dedicated principal that receives burned tokens before they are permanently removed from circulation. Using an explicit burn wallet (rather than the zero address) enables on-chain auditing of total burned supply.

Configuration

rust
// Set the burn wallet principal (controller only)
set_burn_wallet : (principal) -> (Result<(), BurnError>);

// Query the current burn wallet
get_burn_wallet : () -> (principal) query;

// Query total burned supply (sum of all transfers to burn wallet)
total_burned : () -> (nat) query;

Burn Flow

caller.burn(amount)


dom-token validates balance


dom-token.icrc1_transfer({ to: burn_wallet, amount })


AdminEvent { BurnExecuted } persisted


burn_wallet accumulates tokens
  (balance visible on-chain, tokens unspendable — no private key)

The burn wallet principal is set to a canister-controlled address with no spend capability. Total circulating supply = total_minted - total_burned.


Burn Policy Hooks

The dom-token canister supports policy hooks that run before a burn is finalized. The otter-camp canister's DonationProcessor uses these hooks to trigger campaign contribution logic.

rust
type BurnPolicy = variant {
  AllowAll;
  RequireMinimum : nat;       // reject burns below this amount
  RequireApproval : principal; // call this canister for approval
};

set_burn_policy : (BurnPolicy) -> (Result<(), BurnError>);
get_burn_policy : () -> (BurnPolicy) query;

When RequireApproval is set, the canister makes a synchronous inter-canister call to the approval canister before finalizing the burn. If the approval canister returns false, the burn is rejected.


ICRC-1/2 Standard Compliance

The dom-token canister implements the full ICRC-1 and ICRC-2 token standards in addition to V2 extensions.

Key ICRC-1 Methods

rust
icrc1_name         : () -> (text) query;
icrc1_symbol       : () -> (text) query;
icrc1_decimals     : () -> (nat8) query;             // returns 8
icrc1_total_supply : () -> (nat) query;
icrc1_balance_of   : (Account) -> (nat) query;
icrc1_transfer     : (TransferArgs) -> (Result<nat, TransferError>);

Key ICRC-2 Methods (Approvals)

rust
icrc2_approve          : (ApproveArgs) -> (Result<nat, ApproveError>);
icrc2_transfer_from    : (TransferFromArgs) -> (Result<nat, TransferError>);
icrc2_allowance        : (AllowanceArgs) -> (Allowance) query;

The treasury canister uses icrc2_approve to grant itself an allowance from the DAO wallet, then calls icrc2_transfer_from when executing approved payouts.


Canister Upgrade Safety

Critical: Any new fields added to the State struct must be Option<T>. Non-optional fields cause stable_restore to fail on upgrade, wiping all canister state.

rust
// CORRECT — safe upgrade
pub struct State {
    pub balances: HashMap<Principal, u128>,
    pub admin_events: Vec<AdminEvent>,
    pub burn_wallet: Option<Principal>,    // <-- Option<T>
    pub new_field: Option<String>,          // <-- Option<T>
}

// INCORRECT — will wipe state on upgrade from older WASM
pub struct State {
    pub balances: HashMap<Principal, u128>,
    pub new_required_field: String,        // <-- breaks stable_restore
}

#[serde(default)] does NOT help here — Candid deserialization ignores serde attributes. Always use Option<T> for new fields.


Local Development

bash
cd /home/coby/git/dom-token

# Run PocketIC integration tests
cargo test

# Build WASM
cargo build --release --target wasm32-unknown-unknown

# Deploy to local PocketIC
dfx deploy dom-token --network local

# Check current burn wallet on staging
dfx canister call njo7k-3qaaa-aaaau-aed4a-cai get_burn_wallet --network ic

# Query total burned
dfx canister call njo7k-3qaaa-aaaau-aed4a-cai total_burned --network ic

Hello World Co-Op DAO