Skip to main content

How to Build a Secure dApp with AML and CFT Compliance

Updated on
Mar 21, 2025

14 min read

Overview​

Compliance with Anti-Money Laundering (AML) and Counter Financing of Terrorism (CFT) regulations is becoming a priority in decentralized finance (DeFi). Regulatory inspection is increasing, and blockchain projects must implement security measures to prevent illegal financial activities. The app you're about to build leverages QuickNode's Risk Assessment API to evaluate wallet risk profiles and Chainlink Functions to bring this offchain data into smart contracts.

This guide will walk you through building an AML and CFT-compliant staking dApp that integrates QuickNode’s Risk Assessment API and Chainlink Functions to enforce security policies at both the frontend and smart contract levels. Whether you're a developer new to compliance in decentralized finance (DeFi) or an experienced blockchain engineer, this guide will help you understand the why, what, and how of implementing compliance checks in a dApp.


Disclaimer

The smart contracts and code provided in this guide are for educational purposes only and have not been audited. They should not be used in production environments without thorough testing, auditing, and security reviews.

What You Will Learn​

  • Why AML/CFT compliance is critical in DeFi
  • How to evaluate wallet risk using QuickNode’s Risk Assessment API
  • How to enforce compliance onchain using Chainlink Functions
  • How to deploy an AML and CFT compliant staking dApp on Base Mainnet (or other EVM networks)

What You Will Need​

Why AML and CFT Compliance Matters​

The anonymous and permissionless nature of blockchain makes it attractive for both legitimate and illegal financial activities. Authorities worldwide are tightening regulations to combat fraud, sanctions violations, and terrorist financing in DeFi.

Compliance with AML and CFT regulations can be a strategic necessity for blockchain projects. Here’s why:

  • Regulatory Requirements: Governments and financial regulators are increasingly focusing on cryptocurrency and DeFi. Non-compliance can lead to legal actions, fines, or even project shutdowns.
  • Risk Mitigation: Without compliance checks, dApps can become conduits for money laundering or terrorism financing, exposing platforms to illicit activities and reputational damage.
  • User Trust: Implementing compliance measures demonstrates a commitment to security and legality, fostering trust among users and institutions.
  • Future-Proofing: As regulations tighten, embedding compliance into your dApp now can simplify adaptation to future requirements.

QuickNode's Security Add-ons​

QuickNode Marketplace provides two powerful security add-ons to help developers enforce compliance:

  • Risk Assessment API:
    This add-on analyzes offchain data (e.g., transaction patterns, known associations) to assign a risk score (0–100) to wallet addresses. A lower score indicates higher risk, with additional details like severity and entity type (e.g., "Sanction list"). It’s the add-on used in the app you’re about to build.

  • Wallet Risk Checker:
    This add-on leverages AI-powered risk analysis to monitor wallet risks efficiently, providing risk scores, entity labels, and flagging suspicious activities for secure, compliant transactions. It uses AI to parse transaction behavior and identify parties, delivering accurate wallet risk scores alongside entity labels (e.g., "Known Exchange" or "Suspected Scammer"). The Wallet Risk Checker accesses a vast database of global sanctions lists, including US OFAC, United Nations, Canada, UK, EU, Switzerland, and Australia, pulling data from major blockchain networks and global intelligence sources.

Both the Risk Assessment API and Wallet Risk Checker provide risk scores and entity labels via simple API calls, making them easy to integrate into applications for compliance checks. They share a focus on AML/CFT compliance, leveraging offchain data to evaluate wallet risks. For this guide, we focus on the Risk Assessment API but note that the Wallet Risk Checker could be swapped in with minimal adjustments.

Challenges of Onchain Compliance​

Blocking user interactions on the frontend using these add-ons is as simple as making an API call and checking the response. However, enforcing compliance at the smart contract level requires bridging the gap between offchain data and onchain logic, since they operate in a isolated environment and cannot directly access external data sources like APIs.

To overcome this, we need a secure, decentralized solution to fetch and deliver offchain data to the smart contract. This is where Chainlink Functions steps in.

Chainlink Functions is a decentralized oracle solution designed to connect smart contracts with external data sources and offchain computation.

For developers unfamiliar with Chainlink Functions, it extends the capabilities of Chainlink's oracle network by allowing custom JavaScript code to be executed offchain in a secure, decentralized environment. This code can interact with APIs, process data, and return results to the blockchain, all while maintaining the trustlessness and reliability of a decentralized oracle network.

Chainlink Functions Diagram Source: Chainlink Functions Documentation

In the context of the AML and CFT Compliant dApp, Chainlink Functions plays a critical role in enabling onchain compliance by bridging the gap between the smart contract and the QuickNode Risk Assessment API. Here's how it works:


  • Custom JavaScript Execution: Chainlink Functions allows developers to write JavaScript code (e.g., in source.js) that runs offchain. In this dApp, the code calls the Risk Assessment API with a wallet address, extracts the score field from the response (e.g., {"score": 1, "severity": "CRITICAL_RISK"}), and returns the numeric risk score to the blockchain.
  • Decentralized Execution: The JavaScript job is executed by the Chainlink Decentralized Oracle Network (DON), ensuring that the computation is performed in a decentralized and tamper-proof manner, avoiding single points of failure.
  • Secure Callback Mechanism: Once the risk score is retrieved, Chainlink Functions delivers it to the smart contract via the fulfillRequest callback function. This updates the riskScores mapping in the RiskBasedStaking.sol contract, enabling onchain compliance enforcement.
  • Secrets Management: Chainlink Functions makes it easy to use secrets, such as QuickNode Endpoint URL, with either DON-hosted or user-hosted hosting methods. This ensures that API credentials remain confidential and are not exposed onchain, enhancing security.
  • Subscription Model: Chainlink Functions has a subscription model that allows the smart contract to make calls without requiring LINK token transfers for each request.

Project Architecture and Data Flow​

The dApp’s architecture integrates frontend, smart contract, and oracle components seamlessly. Here’s the data flow:


  1. User Interaction: A user connects their wallet to the Next.js frontend and initiates a risk check.
  2. Frontend API Call: The frontend triggers a request to the /api/check-risk route.
  3. Chainlink Functions Request: The API route uses the Chainlink Functions Toolkit to send a request to the Chainlink network.
  4. API Invocation: Chainlink Functions runs a JavaScript job to call the Risk Assessment API with the wallet address.
  5. Risk Score Retrieval: The API returns the risk score (e.g., { "score": 1, "severity": "CRITICAL_RISK" }).
  6. Smart Contract Update: Chainlink Functions calls the smart contract’s fulfillRequest function, updating the wallet’s risk score.
  7. Compliance Enforcement: The smart contract enforces staking rules (e.g., risk score < threshold).
  8. Frontend Update: The frontend reflects the updated risk score and staking status.

Before moving to the deployment section, it’s crucial to understand the components of the smart contract and the Chainlink Functions integration. This section provides more details on how the smart contract and API work together.

Smart Contract Overview: RiskBasedStaking.sol​

The RiskBasedStaking.sol smart contract manages staking operations while enforcing compliance through risk scores fetched via Chainlink Functions. It ensures that only wallets with acceptable risk levels can stake ETH.

Key State Variables​
  • stakedBalances: A mapping (address => uint256) tracking the ETH staked by each user.
  • riskScores: A mapping (address => uint256) storing the risk score for each wallet. A score of 0 indicates the wallet hasn’t been checked yet.
  • riskThreshold: A uint256 value (e.g., 30) representing the minimum risk score required to stake. Lower scores indicate higher risk.
  • pendingRequests: A mapping (address => bool) preventing duplicate risk check requests for the same user.
  • s_lastRequestId, s_lastResponse, s_lastError: Variables storing the most recent Chainlink Functions request ID, response, and error for debugging purposes.
Core Functions​
  • isAllowedToStake(address user)

    • Purpose: Checks if a user can stake.
    • Logic: Returns true if the user’s riskScores[user] is greater than 0 (i.e., checked) and at least equal to riskThreshold.
    • Usage: Called by stake() to enforce compliance.
  • sendRequest(...)

    • Purpose: Initiates a Chainlink Functions request to fetch a risk score.
    • Access: Restricted to the contract owner (onlyOwner).
    • Parameters: Includes source (JavaScript code), args (user’s address), subscriptionId, and gasLimit.
    • Logic:
      • Ensures exactly one argument (the user’s address) is provided.
      • Prevents duplicate requests if pendingRequests[user] is true.
      • Initializes and sends the request using Chainlink’s FunctionsRequest library.
      • Tracks the request by mapping s_lastRequestId to the user’s address.
    • Events: Emits RiskCheckRequested(user, requestId).
  • fulfillRequest(bytes32 requestId, bytes memory response, bytes memory err)

    • Purpose: Processes the Chainlink Functions response.
    • Access: Internal, called automatically by Chainlink.
    • Logic:
      • Retrieves the user’s address from requestToUser[requestId].
      • Clears pendingRequests[user].
      • If no error (err.length == 0), decodes the response as a uint256 risk score, updates riskScores[user], and emits RiskScoreUpdated(user, score).
      • Stores the response/error and emits Response(requestId, response, err).
  • stake()

    • Purpose: Allows users to stake ETH if they pass the risk check.
    • Logic:
      • Checks if riskScores[user] is 0:
        • If pendingRequests[user] is true, reverts with PendingRiskCheck.
        • Otherwise, reverts with NoRiskScore.
      • If riskScores[user] < riskThreshold, reverts with RiskyAddress.
      • Updates stakedBalances[user] and totalStaked, then emits Staked(user, amount).
  • withdraw(uint256 amount)

    • Purpose: Allows users to withdraw staked ETH.
    • Logic:
      • Ensures stakedBalances[user] >= amount, otherwise reverts with InsufficientBalance.
      • Updates balances and transfers ETH to the user.
      • Emits Withdrawn(user, amount).

The /api/check-risk route connects the frontend to the smart contract, using the Chainlink Functions Toolkit to trigger risk assessments. It handles the offchain request process and updates the contract with the result.

Key Configuration​
  • routerAddress: The Chainlink Functions router address (e.g., 0xf9B8fc078197181C841c296C876945aaa425B278 for Base mainnet).
  • donId: The Decentralized Oracle Network (DON) ID (e.g., fun-base-mainnet-1).
  • gatewayUrls: URLs for uploading secrets to the Chainlink gateway.
  • source.js: JavaScript code executed by Chainlink Functions to call the Risk Assessment API and return a risk score.
  • secrets: An object containing sensitive data (e.g., QUICKNODE_ENDPOINT), encrypted and uploaded to the DON.

Chainlink Functions Playground is a great tool for testing and debugging source code. You can use it to simulate requests and view the responses.

Chainlink Functions Playground

Process Flow​

  1. Reading the Source Code: The route reads source.js from the filesystem, which defines the logic to call the Risk Assessment API with the user’s address and extract the risk score.

  2. Setting Up the Signer: Uses ethers.js to create a wallet from PRIVATE_KEY (stored in .env) and connects it to the QuickNode provider (QUICKNODE_ENDPOINT).

  3. Managing Secrets: The SecretsManager encrypts the secrets object (e.g., QUICKNODE_ENDPOINT) and uploads it to the DON with a specified slotId and expiration time.

  4. Sending the Request: Calls sendRequest on the smart contract with:

    • The source.js code.
    • Encrypted secrets reference (slotId and version).
    • The user’s address as args.
    • Chainlink subscription ID and gas limit.
  5. Listening for the Response: The ResponseListener waits for Chainlink’s response from the transaction. Decodes the responseBytesHexstring as a uint256 risk score using decodeResult.

  6. Error Handling: Handles errors like invalid addresses or API failures, returning appropriate JSON responses (e.g., { success: false, error: "Invalid wallet address" }).

Now that you understand the architecture and components of the dapp, let’s walk through the process of setting it up on your local machine or preferred network.

Setting Up the Project​

While Chainlink Functions supports testnets for experimentation (e.g., Sepolia), QuickNode’s security add-ons, such as the Risk Assessment API, are designed to operate on mainnets (you can confirm supported networks on their add-on pages). For this guide, we’ll deploy on Base mainnet to ensure full compatibility with the Risk Assessment API.

Follow these detailed steps to set up the dApp locally or on your preferred network:

Step 1: Clone the Repository​

git clone https://github.com/quiknode-labs/qn-guide-examples.git
cd qn-guide-examples/sample-dapps/aml-and-cft-compliant-dapp

Step 2: Set Up Your QuickNode Endpoint​


  1. Sign up at QuickNode and create an endpoint (e.g., Base mainnet).
  2. Enable the Risk Assessment API add-on in your endpoint. This enables sc_getAddressAnalysis and sc_getTransactionAnalysis methods through your endpoint.
  3. Save your endpoint URL.

Step 3: Configure Foundry and Deploy the Smart Contract​


  1. Install Foundry:
curl -L https://foundry.paradigm.xyz | bash
foundryup

This installs Foundry, a toolkit for Ethereum smart contract development, which we'll use to compile and deploy our contract.


  1. Navigate to Foundry Directory:
cd foundry

  1. Install Dependencies: Install the required dependencies for Chainlink Functions integration and Foundry standards:
forge install smartcontractkit/foundry-chainlink-toolkit --no-commit
forge install foundry-rs/forge-std --no-commit

These libraries provide tools for interacting with Chainlink Functions and standard utilities for smart contract development.


  1. Create the Remappings File: Generate a remappings.txt file to map the installed dependencies correctly in Foundry:
forge remappings > remappings.txt

  1. Import Your Wallet: Securely import your wallet into Foundry for deployment:
cast wallet import your-wallet-name --interactive

Replace your-wallet-name with a name for your wallet (e.g., deployer). You'll be prompted to enter your private key and set a password for encryption. The --interactive flag ensures the private key isn't saved in your shell history for security.


  1. Set Environment Variables:

Copy .env.sample to .env and update:

BASE_RPC_URL=<your-quicknode-endpoint>
BASESCAN_API_KEY=<your-basescan-api-key>

Replace BASE_RPC_URL with your QuickNode endpoint URL and BASESCAN_API_KEY with your BaseScan API key.

Check BaseScan API Keys docs for instructions on how to obtain your BaseScan API key.

Source the variables:

source .env

  1. Update Deployment Script:

Modify the script/RiskBasedStaking.s.sol file by updating the Chainlink Functions router address to match your selected network. Refer to the Chainlink Functions Supported Networks documentation for the appropriate address.

Ensure you use a checksummed Ethereum address to avoid deployment errors. If you're uncertain about checksum validation, utilize a tool like the Address Checksum Tool to generate and verify the correct checksummed format.

script/RiskBasedStaking.s.sol
// Chain-specific Chainlink Functions Router addresses
address router = 0xf9B8fc078197181C841c296C876945aaa425B278;

  1. Deploy the Contract: Deploy the RiskBasedStaking smart contract to Base mainnet (or your chosen network):
forge script script/RiskBasedStaking.s.sol:RiskBasedStakingScript --rpc-url $BASE_RPC_URL --account your-wallet-name --broadcast --verify -vvv

The --verify flag verifies the contract on the explorer (e.g., Basescan); remove it if you don’t want to verify.

Contract Deployment

Note the deployed contract address from the output—you’ll need it for Chainlink Functions and frontend setup.

After deploying the smart contract, you need to set up a Chainlink Functions subscription to allow the contract to request risk assessments. Follow these steps:

  1. Create a Chainlink Functions Subscription

    • Go to the Chainlink Functions UI for your desired network.
    • Follow the instructions to create a new subscription.
    • Fund the subscription with LINK tokens (you can bridge LINK if needed, see LINK token docs).
  2. Add Your Smart Contract as a Consumer

    • Once the subscription is set up, add your deployed contract address as a consumer.
    • This allows the contract to make requests via Chainlink Functions.
  3. Check Network-Specific Details

    • Each network has its own Functions router address and DON ID (Decentralized Oracle Network).
    • Refer to the Chainlink Functions Supported Networks page to find the correct values for your network.
  4. Get Your Subscription ID

    • Visit Chainlink Functions UI and select your subscription.
    • Copy the Subscription ID from the URL, as you'll need it in the next step.

For a detailed setup guide, refer to the Chainlink Functions Documentation.

Step 5: Configure and Run the Frontend​

We use Next.js with TypeScript for type safety, Wagmi and Viem for wallet connectivity and smart contract interactions, and Mantine UI for a polished UI design.


  1. Navigate to Next.js Directory:
cd ../next-app

This directory contains the frontend code, structured with Next.js for server-side rendering and API routes.


  1. Install Dependencies:
npm install

This command installs all required packages listed in package.json. Key dependencies include:

  • next and react: Core libraries for building the Next.js application.
  • wagmi and viem: Libraries for Ethereum wallet connectivity and smart contract interactions, enabling seamless integration with the blockchain.
  • @mantine/core and @mantine/hooks: Mantine UI components and hooks for building a modern, responsive interface.
  • @chainlink/functions-toolkit: A toolkit for interacting with Chainlink Functions, used in the /api/check-risk route to trigger risk assessment requests.
  • ethers: A library for Ethereum utilities, used for signing transactions and interacting with the smart contract.

Why both viem and ethers? Because ethers is a dependency of chainlink-functions-toolkit, and viem is a dependency of wagmi. We need both to interact with the blockchain and perform wallet operations.

  1. Set Environment Variables:
  • Create a .env file in the next-app directory to store environment variables:

  • Add the following variables:

WALLETCONNECT_PROJECT_ID=<your-walletconnect-id>
PRIVATE_KEY=<your-private-key>
QUICKNODE_ENDPOINT=<your-quicknode-endpoint>
CONTRACT_ADDRESS=<deployed-contract-address>
SUBSCRIPTION_ID=<chainlink-subscription-id>

Explanation of Variables and How to Obtain Them:

  • WALLETCONNECT_PROJECT_ID: This is your WalletConnect project ID, required for enabling wallet connections in the frontend. Sign up at WalletConnect Cloud to create a project and obtain your ID (e.g., a1b2c3d4...).
  • PRIVATE_KEY: The private key of a designated wallet used to sign Chainlink Functions requests. This ensures only authorized requests are made, preventing abuse of your Chainlink subscription. You can use the same wallet imported in Step 3 (e.g., your-wallet-name). Export the private key securely using a tool like MetaMask or a wallet CLI, but never expose it publicly.
  • QUICKNODE_ENDPOINT: Your QuickNode RPC endpoint URL with the Risk Assessment API enabled. You obtained this in Step 2 when setting up your QuickNode endpoint (e.g., https://your-endpoint.quicknode.com).
  • CONTRACT_ADDRESS: The address of the deployed RiskBasedStaking.sol contract. You noted this in Step 3 after running the deployment script (e.g., 0x1234...).
  • SUBSCRIPTION_ID: The Chainlink Functions subscription ID, which funds the risk assessment requests. You’ll obtain this in Step 4 when setting up your Chainlink subscription (e.g., 1234).
  1. Update Chainlink Functions Configuration

Update the src/app/api/check-risk/route.ts file with the correct router address and DON ID for your chain and network.

src/app/api/check-risk/route.ts
  // Chainlink Functions configuration
const routerAddress = "0xf9B8fc078197181C841c296C876945aaa425B278";
const donId = "fun-base-mainnet-1";
const gatewayUrls = [
"https://01.functions-gateway.chain.link/",
"https://02.functions-gateway.chain.link/",
];

  1. Run the Application: Start the Next.js development server:
npm run dev

This command launches the app in development mode, with hot reloading enabled for easier debugging.

Testing the Application​

Once your dApp is set up, you can access and interact with it through a browser interface.


  1. Access the Application: Open your browser and navigate to http://localhost:3000 to interact with the dApp. Check your score to test functionality of the app.
App OverviewApp Overview with a Score
Overview without ScoreOverview with Score

  1. Verify Chainlink Functions Subscription:
    • Head to the Chainlink Functions UI to monitor your subscription.
    • Check the request history to ensure your risk check requests are processing successfully.

Chainlink Functions Requests

  1. Interact with the Smart Contract:

Query the riskScores mapping in the smart contract to confirm your risk score is updated correctly:

If you’re new to interacting with smart contracts, follow our guide on interacting with smart contracts for detailed instructions on how to read and write to the contract using tools like ethers.js or a block explorer’s interface.

This verification ensures that the risk score from the Risk Assessment API is properly reflected onchain.

Additional Steps​

To elevate your dApp from a working prototype to a production-ready application, consider these practical improvements:

  • Audit and Security:

    • Conduct a thorough security audit of your smart contract and Chainlink Functions integration to identify and address any potential vulnerabilities. This will help ensure the safety and reliability of your application.
  • Rate Limiting for Risk Checks:

    • Prevent abuse by limiting how often users can request risk checks. For example, enforce a 24-hour cooldown per wallet address to avoid draining your Chainlink subscription’s LINK balance unnecessarily.
    • This can be implemented in the smart contract with a timestamp check.
  • Optimizing LINK and Transaction Costs:

    • LINK Costs: Monitor LINK usage in your Chainlink subscription and set up balance alerts. Reduce costs by batching requests or lowering the frequency of risk checks where possible.
    • Gas Fees: Optimize gas usage by setting an appropriate gasLimit for Chainlink Functions requests and minimizing onchain operations.
  • User Experience Enhancements:

    • Add a loading indicator or real-time notification during risk checks to keep users informed.
    • Provide feedback if a risk score fails the threshold, leveraging metadata (e.g., severity or entityType) from the Risk Assessment API.
  • Security Best Practices:

    • Securely manage your PRIVATE_KEY—never expose it in client-side code. Use a dedicated signer wallet with minimal funds for Chainlink requests.
    • Consider adding access controls or multi-signature requirements for critical updates, like changing the riskThreshold.
  • Testing and Validation:

    • Test extensively on a testnet (if available) to iron out bugs, then pursue a professional audit to ensure your dApp is secure and reliable.

Conclusion​

This guide has equipped you with the steps to build an AML and CFT compliant dApp using QuickNode’s Risk Assessment API and Chainlink Functions. You’ve set up a staking interface, verified your Chainlink subscription and smart contract interactions, and explored ways to enhance your solution. By implementing the additional steps above, you can create a dApp that’s not only compliant but also efficient, secure, and user-friendly.

If you have any questions, feel free to use our Discord server or provide feedback using the form below. Stay up to date with the latest by following us on Twitter and our Telegram announcement channel.

We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Additional Resources​

Share this guide