Skip to content

membership API

ICRC-7 Soul-Bound Token (SBT) canister for member credentials. Membership NFTs are non-transferable and represent co-op ownership.

Candid file: membership/src/membership.didStandard: ICRC-7, ICRC-37

Types

MembershipStatus

candid
type MembershipStatus = variant {
  Active;      // Full membership with voting rights (expires Dec 31)
  Registered;  // Limited membership (no voting, no expiration)
  Expired;     // Past expiration date (Active → Expired)
  Revoked;     // Administratively revoked
};

Status descriptions:

  • Active: Full voting member. Requires payment, KYC, and 18+ age verification. Expires December 31 annually.
  • Registered: Basic membership granted after email verification. No voting rights. No expiration. Free.
  • Expired: Former Active member past expiration date. Reverts to Registered after grace period.
  • Revoked: Membership administratively revoked (rare, for ToS violations).

MembershipMetadata

candid
type MembershipMetadata = record {
  join_date: nat64;          // When member joined
  status: MembershipStatus;
  tos_accepted_at: nat64;    // Terms of Service acceptance
  expiration_date: nat64;    // December 31st 23:59:59 UTC
  is_active: bool;           // Computed active status
};

MembershipRecord

candid
type MembershipRecord = record {
  token_id: nat;
  owner: Account;
  metadata: MembershipMetadata;
};

Account

Standard ICRC-7 account:

candid
type Account = record {
  owner: principal;
  subaccount: opt blob;
};

ICRC-7 Standard Methods

Collection Metadata

icrc7_name (query)

candid
"icrc7_name": () -> (text) query;

Returns: "Hello World DAO Membership"

icrc7_symbol (query)

candid
"icrc7_symbol": () -> (text) query;

Returns: "HWDM"

icrc7_total_supply (query)

candid
"icrc7_total_supply": () -> (nat) query;

Total membership NFTs minted.

Token Queries

icrc7_balance_of (query)

Get membership count for accounts.

candid
"icrc7_balance_of": (vec Account) -> (vec nat) query;

For membership SBTs, returns 0 or 1 per account.

icrc7_owner_of (query)

Get owner of token(s).

candid
"icrc7_owner_of": (vec nat) -> (vec opt Account) query;

icrc7_token_metadata (query)

Get metadata for token(s).

candid
"icrc7_token_metadata": (vec nat) -> (vec opt vec record { text; Value }) query;

Transfer (Soul-Bound)

icrc7_transfer

Always returns NonTransferable error - Membership NFTs cannot be transferred.

candid
"icrc7_transfer": (vec TransferArg) -> (vec opt TransferResult);

type TransferResult = variant {
  Ok: nat;
  Err: TransferError;
};

type TransferError = variant {
  NonTransferable;  // Always returned for SBTs
  Unauthorized;
  NonExistingTokenId;
  InvalidRecipient;
  GenericError: record { error_code: nat; message: text };
};

Membership-Specific Methods

Membership Management

mint_membership_registered

Mint a new membership NFT with Registered status (controller only). Called after email verification.

candid
"mint_membership_registered": (principal, tos_accepted_at: nat64) -> (variant { Ok: nat; Err: text });

Parameters:

  • principal: User's principal ID
  • tos_accepted_at: Timestamp when user accepted Terms of Service

Returns: Token ID on success

TypeScript Example:

typescript
// Called by user-service after email verification
const result = await membershipActor.mint_membership_registered(
  Principal.fromText(userPrincipal),
  BigInt(Date.now()) * 1_000_000n  // ToS acceptance timestamp
);

if ('Ok' in result) {
  console.log('Minted Registered token ID:', result.Ok);
}

Note: Registered memberships have no expiration date and are not checked by the heartbeat task.

upgrade_to_active

Upgrade a Registered membership to Active status (controller only). Called after KYC and payment.

candid
"upgrade_to_active": (principal, payment_proof: blob) -> (variant { Ok; Err: text });

Parameters:

  • principal: User's principal ID
  • payment_proof: Attestation from oracle-bridge confirming payment

Returns: Ok on success, Err with reason on failure

TypeScript Example:

typescript
// Called by user-service after KYC approval and payment
const result = await membershipActor.upgrade_to_active(
  Principal.fromText(userPrincipal),
  paymentProofBlob  // From oracle-bridge payment confirmation
);

if ('Ok' in result) {
  console.log('Upgraded to Active membership');
}

Status transition: Registered → Active

Side effects:

  • Sets expiration date to December 31, 23:59:59 UTC of current year
  • Enables voting rights
  • Adds membership to heartbeat expiration checks

mint_membership (deprecated)

Legacy method for minting Active memberships directly. Replaced by the two-step flow (mint_membership_registeredupgrade_to_active). Still available for backwards compatibility.

candid
"mint_membership": (principal, tos_accepted_at: nat64) -> (variant { Ok: nat; Err: text });

Use mint_membership_registered instead for new integrations.

get_membership_status (query)

Get the membership status for a principal.

candid
"get_membership_status": (principal) -> (opt MembershipStatus) query;

Returns: Some(status) if membership exists, None if no membership found.

TypeScript Example:

typescript
const status = await membershipActor.get_membership_status(userPrincipal);

if (status) {
  switch (status) {
    case 'Active':
      console.log('Full voting member');
      break;
    case 'Registered':
      console.log('Registered member (no voting)');
      break;
    case 'Expired':
      console.log('Membership expired');
      break;
    case 'Revoked':
      console.log('Membership revoked');
      break;
  }
}

verify_membership (query)

Check if principal has active membership.

candid
"verify_membership": (principal) -> (opt MembershipRecord) query;

Note: Returns Some(record) only for Active memberships. Registered memberships return None.

is_member (query)

Quick boolean check for Active membership.

candid
"is_member": (principal) -> (bool) query;

Returns: true only for Active status. Registered, Expired, and Revoked return false.

Important: This method is used for governance eligibility checks. Only Active members can vote.

Membership Status

is_membership_expired (query)

Check if membership has expired.

candid
"is_membership_expired": (principal) -> (variant { Ok: bool; Err: text }) query;

is_eligible_to_vote (query)

Check if member can vote (Active status required).

candid
"is_eligible_to_vote": (principal) -> (variant { Ok: bool; Err: text }) query;

Returns: Ok(true) only for Active memberships. Registered memberships return Ok(false).

Note: Voting eligibility requires Active status. Registered members cannot vote even if they have a valid NFT.

can_renew (query)

Check if in renewal window (Dec 1 - Jan 31).

candid
"can_renew": (principal) -> (variant { Ok: bool; Err: text }) query;

Renewal and Lifecycle

renew_membership

Renew an existing membership.

candid
"renew_membership": (principal, attestation: blob) -> (variant { Ok; Err: text });

revoke_membership

Revoke membership (controller only).

candid
"revoke_membership": (principal) -> (variant { Ok; Err: text });

update_membership_status

Update status directly (controller only).

candid
"update_membership_status": (principal, MembershipStatus) -> (variant { Ok; Err: text });

Proration (First-Year Members)

is_first_year_member (query)

Check if member joined mid-year.

candid
"is_first_year_member": (principal) -> (variant { Ok: bool; Err: text }) query;

get_prorated_dividend (query)

Calculate prorated dividend amount.

candid
"get_prorated_dividend": (principal, total_dividend: nat64) -> (variant { Ok: nat64; Err: text }) query;

Session-Based Methods

For email/password authenticated users:

verify_membership_with_session

candid
"verify_membership_with_session": (session_token: text) -> (variant { Ok: opt MembershipRecord; Err: text });

can_renew_with_session

candid
"can_renew_with_session": (session_token: text) -> (variant { Ok: bool; Err: text });

Statistics

get_active_members (query)

Get all active member principals.

candid
"get_active_members": () -> (vec principal) query;

get_active_membership_count (query)

Get count of active memberships.

candid
"get_active_membership_count": () -> (nat) query;

Heartbeat Behavior

The membership canister uses a heartbeat task to check for expired Active memberships:

  • Registered memberships: Skipped (no expiration)
  • Active memberships: Checked against December 31 expiration
  • Expired Active memberships: Transitioned to Expired status, later reverted to Registered

Grace period: Active members have until February 28 to renew before status changes to Registered.

Error Messages

ErrorCauseResolution
Not a memberNo membership NFT foundComplete signup flow
Membership expiredActive membership past December 31Renew membership
Not in renewal windowOutside Dec 1 - Jan 31Wait for renewal window
Already has membershipDuplicate mint attemptCheck existing membership
UnauthorizedNot controllerUse authorized identity
Cannot upgrade: not 18+Age verification failed for upgradeUser must be 18+ to upgrade to Active
Cannot upgrade: already ActiveAttempting to upgrade Active membershipUser already has Active status

Hello World Co-Op DAO