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
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
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
type MembershipRecord = record {
token_id: nat;
owner: Account;
metadata: MembershipMetadata;
};Account
Standard ICRC-7 account:
type Account = record {
owner: principal;
subaccount: opt blob;
};ICRC-7 Standard Methods
Collection Metadata
icrc7_name (query)
"icrc7_name": () -> (text) query;Returns: "Hello World DAO Membership"
icrc7_symbol (query)
"icrc7_symbol": () -> (text) query;Returns: "HWDM"
icrc7_total_supply (query)
"icrc7_total_supply": () -> (nat) query;Total membership NFTs minted.
Token Queries
icrc7_balance_of (query)
Get membership count for accounts.
"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).
"icrc7_owner_of": (vec nat) -> (vec opt Account) query;icrc7_token_metadata (query)
Get metadata for token(s).
"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.
"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.
"mint_membership_registered": (principal, tos_accepted_at: nat64) -> (variant { Ok: nat; Err: text });Parameters:
principal: User's principal IDtos_accepted_at: Timestamp when user accepted Terms of Service
Returns: Token ID on success
TypeScript Example:
// 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.
"upgrade_to_active": (principal, payment_proof: blob) -> (variant { Ok; Err: text });Parameters:
principal: User's principal IDpayment_proof: Attestation from oracle-bridge confirming payment
Returns: Ok on success, Err with reason on failure
TypeScript Example:
// 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_registered → upgrade_to_active). Still available for backwards compatibility.
"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.
"get_membership_status": (principal) -> (opt MembershipStatus) query;Returns: Some(status) if membership exists, None if no membership found.
TypeScript Example:
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.
"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.
"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.
"is_membership_expired": (principal) -> (variant { Ok: bool; Err: text }) query;is_eligible_to_vote (query)
Check if member can vote (Active status required).
"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).
"can_renew": (principal) -> (variant { Ok: bool; Err: text }) query;Renewal and Lifecycle
renew_membership
Renew an existing membership.
"renew_membership": (principal, attestation: blob) -> (variant { Ok; Err: text });revoke_membership
Revoke membership (controller only).
"revoke_membership": (principal) -> (variant { Ok; Err: text });update_membership_status
Update status directly (controller only).
"update_membership_status": (principal, MembershipStatus) -> (variant { Ok; Err: text });Proration (First-Year Members)
is_first_year_member (query)
Check if member joined mid-year.
"is_first_year_member": (principal) -> (variant { Ok: bool; Err: text }) query;get_prorated_dividend (query)
Calculate prorated dividend amount.
"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
"verify_membership_with_session": (session_token: text) -> (variant { Ok: opt MembershipRecord; Err: text });can_renew_with_session
"can_renew_with_session": (session_token: text) -> (variant { Ok: bool; Err: text });Statistics
get_active_members (query)
Get all active member principals.
"get_active_members": () -> (vec principal) query;get_active_membership_count (query)
Get count of active memberships.
"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
| Error | Cause | Resolution |
|---|---|---|
Not a member | No membership NFT found | Complete signup flow |
Membership expired | Active membership past December 31 | Renew membership |
Not in renewal window | Outside Dec 1 - Jan 31 | Wait for renewal window |
Already has membership | Duplicate mint attempt | Check existing membership |
Unauthorized | Not controller | Use authorized identity |
Cannot upgrade: not 18+ | Age verification failed for upgrade | User must be 18+ to upgrade to Active |
Cannot upgrade: already Active | Attempting to upgrade Active membership | User already has Active status |