Building a Token Distribution dApp with Concordium Protocol Level Tokens on DevNet

Protocol Level Tokens (PLTs) operate directly within Concordium's blockchain protocol. This architecture delivers better performance and simpler compliance than traditional smart contract tokens, whilst removing security concerns related to smart contracts. This article shows how to build a token distribution system that verifies user identity and automatically distributes tokens to eligible recipients. You can test out the dApp by following this link (you need a Concordium DevNet wallet to be able to use it). You can have a look at the complete code in this repository.

Understanding the dApp Architecture

The dApp creates a complete token distribution workflow. Identity verification integrates with automated token operations to ensure only eligible users receive tokens.

The system operates in three phases:

Identity Verification: Users connect their Concordium wallet and generate zero-knowledge proofs demonstrating EU nationality.

Allowlist Management: Verified users are added to the token's allowlist using PLT governance functions.

Token Distribution: The system mints new tokens and transfers them to verified users' wallets automatically.

Setting Up the Backend Foundation

The backend establishes connections to the Concordium network and manages governance credentials. PLT operations require proper authentication and connection management:

// concordium.service.ts - Core blockchain connectivity

@Injectable()
export class ConcordiumService implements OnModuleInit {

  private client: ConcordiumGRPCNodeClient

  private initializeClient() {
    const grpcHost = this.configService.get('CONCORDIUM_GRPC_HOST', 'localhost')
    const grpcPort = this.configService.get('CONCORDIUM_GRPC_PORT', '00000')
    const useSSL = this.configService.get('CONCORDIUM_USE_SSL', 'true') === 'true'

    this.client = new ConcordiumGRPCNodeClient(
      grpcHost,
      Number(grpcPort),
      useSSL ? credentials.createSsl() : credentials.createInsecure()
    )
  }

  // Load governance wallet for PLT operations
  loadGovernanceWallet(): { sender: AccountAddress.Type; signer: AccountSigner } {
    const walletPath = this.configService.get('GOVERNANCE_WALLET_PATH', './wallet/wallet.export')
    const walletFile = readFileSync(walletPath, 'utf8')
    const walletExport = parseWallet(walletFile)
    const sender = AccountAddress.fromBase58(walletExport.value.address)
    const signer = buildAccountSigner(walletExport)
    return { sender, signer }
  }
}

The governance wallet controls all token operations. This wallet must have governance rights when you create the PLT. The buildAccountSigner function creates a cryptographic signer that authorizes blockchain transactions.

Implementing Core PLT Operations

Every PLT operation starts by creating a token instance. The Concordium Web SDK provides a consistent pattern across all operations.

Token Minting

The mint service creates new tokens using the governance account's privileges:

// mint.service.ts - Token minting operations

async mintTokens(dto: MintTokensDto): Promise<string> {

  const client = this.concordiumService.getClient()

  const { sender, signer } = this.concordiumService.loadGovernanceWallet()

  // Parse token ID and amount using PLT utilities

  const tokenId = TokenId.fromString(dto.tokenId)

  const tokenAmount = TokenAmount.fromDecimal(dto.amount)

  // Create token instance and execute mint operation

  const token = await V1.Token.fromId(client, tokenId)

  const transaction = await V1.Governance.mint(token, sender, tokenAmount, signer)

  await this.concordiumService.waitForTransactionFinalization(transaction)

  return transaction.toString()

}

The V1.Token.fromId function retrieves the token's current state from the blockchain. This includes metadata, supply information, and governance configuration. Only accounts with governance privileges can create new tokens.

Allowlist Management

PLTs include native allowlist functionality that controls which accounts can hold or transfer tokens:

// allowlist.service.ts - Managing token access control

async addUserToAllowList(userAccount: string, tokenId: string): Promise<string> {

  const userAddress = this.concordiumService.parseAccountAddress(userAccount)

  const client = this.concordiumService.getClient()

  const { sender, signer } = this.concordiumService.loadGovernanceWallet()

  // Create token instance and add user to allowlist

  const token = await V1.Token.fromId(client, TokenId.fromString(tokenId))

  const transaction = await V1.Governance.addAllowList(token, sender, userAddress, signer)

  await this.concordiumService.waitForTransactionFinalization(transaction)

  return transaction.toString()

}

async isUserOnAllowList(userAccount: string, tokenId?: string): Promise<boolean> {

  const userAddress = this.concordiumService.parseAccountAddress(userAccount)

  const client = this.concordiumService.getClient()

  // Get account information including PLT token states

  const accountInfo = await client.getAccountInfo(userAddress)

  const tokenAccountInfo = accountInfo.accountTokens

  // Find the specific token and check allowlist status

  const tokenInfo = tokenAccountInfo.find(balance => 

    balance.id.symbol === (tokenId || this.defaultTokenId)

  )

  return tokenInfo?.state.memberAllowList === true

}

The protocol maintains allowlist status automatically. When you query an account's information, the blockchain returns detailed token states including allowlist membership, denylist status, and current balances.

Token Transfers

Token transfers automatically validate allowlist constraints:

// payment.service.ts - Handling token transfers

async transferTokens(dto: TransferTokensDto): Promise<string> {

  const client = this.concordiumService.getClient()

  const recipientAddress = this.concordiumService.parseAccountAddress(dto.to)

  const { sender, signer } = this.concordiumService.loadGovernanceWallet()

  const tokenId = TokenId.fromString(dto.tokenId)

  const amount = TokenAmount.fromDecimal(dto.amount)

  const token = await V1.Token.fromId(client, tokenId)

  // Create transfer with optional memo

  const transfer: V1.TokenTransfer = {

    recipient: recipientAddress,

    amount,

    memo: dto.memo ? CborMemo.fromString(dto.memo) : undefined

  }

  const transaction = await V1.Token.transfer(token, sender, transfer, signer)

  await this.concordiumService.waitForTransactionFinalization(transaction)

  return transaction.toString()

}

The transfer operation validates that both sender and recipient can hold the token according to allowlist and denylist rules. If either account lacks permissions, the transaction fails at the protocol level.

Coordinating the Complete Workflow

The system orchestrates multiple PLT operations in sequence. Order matters because PLT transfers automatically validate allowlist membership:

// allowlist.service.ts - Multi-step process coordination

private async executeAllowListProcess(processId: string, dto: AddToAllowListDto, tokenId: string) {

  const defaultMintAmount = this.configService.get('DEFAULT_MINT_AMOUNT', '100')

  // Step 1: Add to allow list FIRST
  const allowListTxHash = await this.addUserToAllowList(dto.userAccount, tokenId)

  // Step 2: Mint tokens AFTER user is on allow list
  const mintTxHash = await this.mintService.mintTokens({
    tokenId,
    amount: parseInt(defaultMintAmount),
  })

  // Step 3: Transfer tokens to user
  const transferTxHash = await this.paymentService.transferTokens({
    tokenId,
    to: dto.userAccount,
    amount: parseInt(defaultMintAmount),
  })

  // Mark entire process as completed
  this.processTrackingService.markProcessCompleted(processId, {
    allowListTransactionHash: allowListTxHash,
    mintTransactionHash: mintTxHash,
    transferTransactionHash: transferTxHash,
    tokensTransferred: parseInt(defaultMintAmount),
  })
}

Users must be added to the allowlist before receiving tokens. The process tracking service provides real-time status updates to the frontend.

This workflow currently requires three separate blockchain transactions. Future PLT protocol improvements will combine all three operations into a single atomic transaction.

Integrating Concordium ID Verification

Concordium accounts link to real-world identities through a privacy-preserving system. Users establish an identity by completing verification with an identity provider using official documents. This creates cryptographic credentials containing verified attributes like nationality, date of birth, and country of residence.

The system generates zero-knowledge proofs about these attributes without revealing personal information. You can explore different proof types using the Proof Explorer. For comprehensive integration guidance, see the Using ID in dApps tutorial.

The frontend demonstrates identity verification integration:

// AllowListDApp.tsx - Requesting EU nationality proof

const requestCitizenshipProof = async () => {

  // Define EU country codes for nationality verification

  const euCountryCodes = ["AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", 
                          "FI", "FR", "GR", "HR", "HU", "IE", "IT", "LT", "LU", 
                          "LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK"];

  // Create zero-knowledge proof statement using AttributeInSet

  const nationalityStatement: AtomicStatementV2 = {
    type: StatementTypes.AttributeInSet,
    attributeTag: 'nationality',
    set: euCountryCodes
  };

  const credentialStatements: CredentialStatements = [{
    statement: [nationalityStatement],
    idQualifier: {
      type: 'cred' as const,
      issuers: [0] // Identity Provider 0 on devnet
    }
  } as CredentialStatement];

  // Generate cryptographic challenge to prevent replay attacks

  const challengeBuffer = new Uint8Array(32);
  crypto.getRandomValues(challengeBuffer);
  const challenge = Buffer.from(challengeBuffer).toString('hex') as HexString;

  // Request proof from wallet and verify

  const proof = await provider.requestVerifiablePresentation(challenge, credentialStatements);
  const resp = await fetch(`${getVerifierURL()}/v0/verify`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: proof.toString(),
  });

  if (resp.ok) {
    setProofStatus('✅ Proof verified successfully!');
    await startAllowListProcess();
  }

};

The AttributeInSet statement verifies that the user's nationality falls within EU country codes without revealing the specific country. The idQualifier specifies which identity providers you trust. Challenge generation creates unique values that prevent replay attacks.

The dApp currently uses AttributeInSet to verify EU nationality, but Concordium supports a comprehensive range of proof types for different verification needs. You can explore available statement builders in the AccountStatementBuilders.tsx file from the proof explorer repository. This includes reveal statements(showing specific attributes like first name or nationality), range proofs (proving age between specific bounds), membership proofs (checking if attributes like country of residence are in a set), and non-membership proofs (proving users are NOT from sanctioned countries). You can also verify document validity periods, ID document types, and tax ID information. The SDKs provide helper functions for common use cases like EU nationality checks and age verification, making it simple to adapt this dApp for different compliance requirements by modifying the proof statements.

The dApp includes a complete verifier service (services/web3id-verifier-ts) that connects to the Concordium DevNet and validates cryptographic proofs. For more information about the verifier and a rust implementation, see the Concordium Web3ID Verifier repository.

Conclusion

You now have a working token distribution system that combines PLT operations with identity verification. The example shows how Concordium's protocol-level approach simplifies what would otherwise require complex smart contract development and external compliance tools.

The three-step workflow - identity verification, allowlist management, and token distribution - demonstrates the power of integrating blockchain-native identity with protocol-level tokens. As PLT development continues, expect even simpler implementations when the planned single-transaction workflow becomes available.

You can find the complete code in this repository and adapt it for your own needs. Try experimenting with different identity proofs to extend the allowlist criteria to match your specific compliance requirements.

The combination of PLTs and Concordium ID opens up possibilities for building financial applications that traditional blockchains struggle to support. Whether you're building stablecoin distribution systems, regulated token sales, or cross-border payment systems with built-in compliance, this foundation gives you the tools to start building today.

0
Subscribe to my newsletter

Read articles from Dragos Gabriel Danciulescu directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dragos Gabriel Danciulescu
Dragos Gabriel Danciulescu

Developer Relations Engineer at Concordium, specializing in blockchain technology, cryptography, and developer education. I'm passionate about making complex technologies accessible to developers, with a particular focus on cryptographic systems - a passion that began during my university studies with Joan Daemen, creator of the AES encryption standard. I worked extensively with both monolithic and microservices architectures, and currently drive blockchain adoption by creating dApps, SDKs, and educational content for the Concordium blockchain. Through my role at Concordium, I help developers build privacy-preserving, compliant blockchain applications using Protocol Level Tokens and zero-knowledge proofs.