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.
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.