Integrating Celo's Decentralized Identity (DID) system into a Mobile App using Flutter

Integrating Celo's Decentralized Identity (DID) system into a Mobile App using Flutter https://celo.academy/uploads/default/optimized/2X/0/0d50dfeaa9c5562cec579b519b1c2366a850a39a_2_1024x576.png
none 0.0 0

Introduction

Celo is a blockchain platform that provides a decentralized identity (DID) system, allowing users to securely manage their identities and interact with various services. In this tutorial, we’ll learn how to integrate Celo’s DID system into a mobile app using Flutter and the Celo WalletConnect SDK. By doing so, we can leverage Celo’s secure and inclusive identity infrastructure to enhance the functionality and security of our mobile app.

Prerequisites

Before starting this tutorial, it is recommended to have some prior knowledge and complete the following prerequisites:

  1. Basic understanding of blockchain technology (specifically CELO) and smart contracts.
  2. Familiarity with the Flutter framework and Dart language.
  3. Prior experience with EVM would be helpful, as Celo is fully compatible with Ethereum’s tooling and coding conventions.

If you haven’t met these prerequisites, I recommend you take the time to familiarize yourself with the basics of blockchain and Flutter development before proceeding.

Requirements

Before we begin, make sure you have the following requirements met:

  1. Flutter Development Environment: Ensure that you have set up a Flutter development environment on your machine. You can follow the official Flutter installation guide for your operating system to get started.
  2. Solidity: We will use Solidity for writing smart contracts.
  3. A Celo Wallet: Make sure to set up a wallet on Celo’s Alfajores Testnet. You can do this by following the steps from the Setting up a Celo Wallet section in this tutorial.
  4. Fund your wallet using the Celo Faucet. To do this, copy the wallet address and visit the Celo Faucet. Then paste your wallet address into the designated field and confirm.
  5. Ensure Nodejs is installed on your computer. If not, download it from here and install it on your computer following the instructions given.

You can check out this video on how to install and set up the celo-wallet-extension on your computer.

Project Initialization

  • In your terminal run
    npx @celo/celo-composer@latest create
    This will prompt you to select the framework and template you want to use. In this tutorial, we’ll be using the Flutter as the front-end framework.

  • Once you have selected the Futter framework, you will be asked to select the smart contract framework which is the smart-contract development tool we’ll be using for this tutorial.
    Select Hardhat for this tutorial.

  • After selecting the tool, you will be asked if you want subgraph support for your dApp. Select “No” as we will not be using a subgraph for the dApp in this tutorial.

  • Finally, you will be asked to enter the name of the project. Enter the project name of your choice and press the enter key.

Once it’s successfully created, open the project folder you created in the code editor (i’ll be using VSCode). The folder structure should look like this:

In the packages folder, you should see the flutter-app and hardhat folders. The hardhat folder contains the hardhat-project with the necessary setup needed to create and deploy our simple smart contract while the flutter-app folder contains the flutter starter files for our DApp.

  • Navigate to your root directory in your terminal to run this command to install the dependencies in the package.json files.
    yarn install
    Note that you must have yarn already installed on your machine.

After all dependencies have been installed, we can now go ahead to create our smart contract.

Building the Smart Contract

The contract folder in the hardhat folder contains some solidity files, go ahead and delete the files. Then create a new file in the contracts folder and name it RegisterDId.sol
In the RegisterDid.sol file, copy and paste this code.

pragma solidity ^0.8.6;

contract CeloDIDRegistry {
    struct Identity {
        bytes32 did;
        string identifier;
        bool isVerified;
    }

    mapping(address => Identity) identities;
    mapping(address => bool) addressExists;

    event DIDSet(address indexed wallet, bytes32 did);
    event DIDChanged(address indexed wallet, bytes32 newDid);
    event DIDVerified(address indexed wallet);

    
    // 1. Register DID
    function registerDID(address _address, string memory _did, string memory _identifier) public returns (bytes32) {
        require(!addressExists[_address], "Address already registered");
        require(!identities[_address].isVerified, "DID already registered and verified");
        bytes32 did = keccak256(abi.encodePacked(_did));
        identities[_address] = Identity(did, _identifier, false);
        addressExists[_address] = true;
        
        emit DIDSet(_address, did);

        return did;
    }

   // 2. Change DID
    function changeDID(address _address, bytes32 _currentDID, string memory _newDID, string memory _identifier, string memory _newIdentifier) public returns (bytes32){
        require(addressExists[_address], "Address not registered");
        require(identities[_address].did == _currentDID, "Invalid current DID");
        require(identities[_address].isVerified, "Not verified");
        require(keccak256(abi.encodePacked(identities[_address].identifier)) == keccak256(abi.encodePacked(_identifier)), "Invalid identifier");

        bytes32 newDID = keccak256(abi.encodePacked(_newDID));
        identities[_address].did = newDID;
        identities[_address].identifier = _newIdentifier;

        emit DIDChanged(_address, newDID);

        return newDID;
    }

     // 3. Verify DID
    function verifyDID(address _address, bytes32 _did, string memory _identifier) public  returns (bool){
        require(addressExists[_address], "Address not registered");
        require(identities[_address].did == _did, "Invalid DID");
        require(!identities[_address].isVerified, "DID already registered and verified");
        require(keccak256(abi.encodePacked(identities[_address].identifier)) == keccak256(abi.encodePacked(_identifier)), "Invalid identifier");
        
        identities[_address].isVerified = true;

        emit DIDVerified(_address);

        return identities[_address].isVerified;
    }

    // 4. Get DID by Address
    function getDIDByAddress(address _address, string memory _identifier) public view returns (bytes32) {
        require(identities[_address].did != 0, "Address does not exist");
        require(keccak256(abi.encodePacked(identities[_address].identifier)) == keccak256(abi.encodePacked(_identifier)), "Invalid identifier");

        return identities[_address].did;
    }

    // 5. Authenticate User
    function authenticateUser(address _address, bytes32 _did) public view returns (bool) {
        require(addressExists[_address], "Address not registered");
        require(identities[_address].isVerified, "User is not verified");

        if (identities[_address].did == _did) {
            return true;
        }

        return false;
    }
}
  1. We declared the contract and called it CeloDIDRegistry, which consists the struct, maps, events and the functions we’ll be using in this tutorial
struct Identity {
        bytes32 did;
        string identifier;
        bool isVerified;
    }
  1. Then we created a struct named Identity which defines the properties of the DID. It contains the user’s DID in bytes32 form, and the isVerified boolean property to determine if a user is verified or not.
mapping(address => Identity) identities;
mapping(address => bool) addressExists;

event DIDSet(address indexed wallet, bytes32 did);
event DIDChanged(address indexed wallet, bytes32 newDid);
event DIDVerified(address indexed wallet)
  1. We created the identities mapping which associates or maps the user wallet address to the Identity which contains the DID and the isVerified property.
    We also created the addressExists mapping which associates or maps the user wallet address to a boolean value that shows if an address exists or not.

  2. Then the DIDSet event is emitted when the DID is registered by the user, the DIDChanged event is emitted when the user changes their DID and the DIDVerified event is emitted when the user verifies their DID using their wallet address.

 // 1. Register DID
    function registerDID(address _address, string memory _did, string memory _identifier) public returns (bytes32) {
        require(!addressExists[_address], "Address already registered");
        require(!identities[_address].isVerified, "DID already registered and verified");
        bytes32 did = keccak256(abi.encodePacked(_did));
        identities[_address] = Identity(did, _identifier, false);
        addressExists[_address] = true;
        
        emit DIDSet(_address, did);

        return did;
    }

   // 2. Change DID
    function changeDID(address _address, bytes32 _currentDID, string memory _newDID, string memory _identifier, string memory _newIdentifier) public returns (bytes32){
        require(addressExists[_address], "Address not registered");
        require(identities[_address].did == _currentDID, "Invalid current DID");
        require(identities[_address].isVerified, "Not verified");
        require(keccak256(abi.encodePacked(identities[_address].identifier)) == keccak256(abi.encodePacked(_identifier)), "Invalid identifier");

        bytes32 newDID = keccak256(abi.encodePacked(_newDID));
        identities[_address].did = newDID;
        identities[_address].identifier = _newIdentifier;

        emit DIDChanged(_address, newDID);

        return newDID;
    }
  1. The registerDID function takes in the user wallet address, the did and the user’s unique identifier, hashes the did and registers it in the map created. Checks are done to know if the DID has already been registered.

  2. Also the ChangeDID function takes in the user address, the current or old DID, the new did(to be hashed) and the user’s current and new unique identifiers. The function checks if the address exists, it also checks if the current did and identifier match the did and identifier mapped or associated to the address and then it hashes and replaces the old did with the new did which comes in the format
    did:celo:{your identifier}.

 // 3. Verify DID
    function verifyDID(address _address, bytes32 _did, string memory _identifier) public  returns (bool){
        require(addressExists[_address], "Address not registered");
        require(identities[_address].did == _did, "Invalid DID");
        require(!identities[_address].isVerified, "DID already registered and verified");
        require(keccak256(abi.encodePacked(identities[_address].identifier)) == keccak256(abi.encodePacked(_identifier)), "Invalid identifier");
        
        identities[_address].isVerified = true;

        emit DIDVerified(_address);

        return identities[_address].isVerified;
    }

    // 4. Get DID by Address
    function getDIDByAddress(address _address, string memory _identifier) public view returns (bytes32) {
        require(identities[_address].did != 0, "Address does not exist");
        require(keccak256(abi.encodePacked(identities[_address].identifier)) == keccak256(abi.encodePacked(_identifier)), "Invalid identifier");

        return identities[_address].did;
    }
  1. The verifyDID function takes in the user’s address, the user’s Celo did and user’s identifier and then changes the value of the isVerified property of the Identity associated to the wallet address provided to true. Necessary checks are done to ensure the address exists.

  2. The getDIDByAddress function takes in the user’s address and the user’s unique identifier, compares the entered identifier to the one registered on the contract with the address, and returns the Celo DID associated with the address.

// 5. Authenticate User
    function authenticateUser(address _address, bytes32 _did) public view returns (bool) {
        require(addressExists[_address], "Address not registered");
        require(identities[_address].isVerified, "User is not verified");

        if (identities[_address].did == _did) {
            return true;
        }

        return false;
    }
  1. The authenticateUser function takes in the user’s address and celo DID, checks if the user exists and the DID entered is mapped to the user’s address on the contract.

Compiling the Smart Contract

Before deploying your smart contract to the Celo blockchain, you will have to compile the smart contract using a command in your terminal.

  • To compile the smart contract, run these commands to navigate t the hardhat directory from the root directory of your project.
  cd packages
  cd hardhat
  • Then create a .env file in the hardhat folder and add your MNEMONIC (phrases) key in the .env file or make use of your private key.
 MNEMONIC=//add your wallet seed phrase here
 PRIVATE_KEY= ‘<your private key here>’
  • Install the dotenv package to be able to import the env file and use it in the config.
    Enter this command in your terminal to install the dotenv package
    yarn add dotenv

  • Next, open the hardhat.config.js file and replace the content of the file with the code below:
    Note that the solidity version depends on the pragma version you used in your smart contract file.

require("@nomicfoundation/hardhat-chai-matchers")
require('dotenv').config({path: '.env'});
require('hardhat-deploy');


// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more


/**
 * @type import('hardhat/config').HardhatUserConfig
 */
module.exports = {
  defaultNetwork: "alfajores",
  networks: {
    localhost: {
        url: "http://127.0.0.1:7545"
    },
    alfajores: {
      gasPrice: 200000000000,
      gas: 41000000000,
      url: "https://alfajores-forno.celo-testnet.org",
      accounts: [process.env.PRIVATE_KEY],
      // {
      //   mnemonic: process.env.MNEMONIC,
      //   path: "m/44'/52752'/0'/0"
      // },
      chainId: 44787
    },
    celo: {
      url: "https://forno.celo.org",
      accounts: {
        mnemonic: process.env.MNEMONIC,
        path: "m/44'/52752'/0'/0"
      },
      chainId: 42220
    },    
  },
  solidity: "0.8.6",
};
  • Now to compile the contract, run this command in your terminal.
npx hardhat compile
  • Once the compilation is successful, you should see the message below on your terminal:

Deploying the Smart Contract

We will be deploying the smart contract to the Alfajores testnet. To do this,

  • Replace the content of the 00-deploy.js in the deploy folder with the code below:
const hre = require("hardhat");


async function main() {
  const CeloDIDRegistry = await hre.ethers.getContractFactory("CeloDIDRegistry");
  const celoDIDRegistry = await CeloDIDRegistry.deploy();


  await celoDIDRegistry.deployed();


  console.log("CeloDIDRegistry deployed to:", celoDIDRegistry.address);
}


main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });
  • Then, run the following command in your terminal to deploy the smart contract to the Celo blockchain.
npx hardhat run deploy/00-deploy.js --network alfajores
  • Save the smart contract address that will be printed on your terminal when deployment is successful somewhere because you will need it in the course of the tutorial.

  • For the purpose of this tutorial, inside the flutter project, create a .env file, create variables inside the .env file called “CONTRACT_ADDRESS” and “PRIVATE_KEY”, then assign the address returned after deploying your smart contract to celo alfajores network.

  • Also copy the contract abi from packages/hardhat/artifacts/contracts/RegisterDid.sol/CeloDIDRegistry.json file, and then create a new file inside your flutter project called “did.abi.json”, paste the ABI copied inside this file.
    In this tutorial, the did.abi.json file is located inside the flutter-app/lib/module/services folder.

Building the DApp/User Interface

  1. We will be building a simple UI where the user will have to register the wallet and generate a Celo DID through the unique identifier they enter.

  2. Then we’ll build the screen to interact with other DApps or smart contracts and make a decentralized payment using the generated Celo DID as a means of authentication for the user. This keeps the DApp secure and allows verified and authorized users to interact with the DApps and also make a secured payment on the platform.

  3. We’ll build the Register DID screen, this screen enables users to register a new wallet address and generates a Celo DID to be used as a means of authentication for interaction on the platform.

  4. We’ll also build the screen to verify your DID, this should serve as a double check on the DID initially registered, users must verify their DID before being authorized to interact with any smart contract/DApps or making payment.

  5. We’ll also build the UI for users to change their DID, the user will be prompted to enter their wallet address, the current DID, the current identifier and then the new unique identifier to create a new DID for them.

  6. We’ll lastly build the screen for users to view their DID by entering their wallet address and their unique identifier. This identifier serves as a security to ensure other people who have access to your address don’t access your Celo DID.

Now let’s get started.

  • Ensure you navigate to the flutter project folder from the root folder by running the following commands:
 cd packages
 cd flutter-app
  • We’ll start by adding the dependencies to the pubspec.yaml file or run the command below in your terminal.
    To keep it simple, we will be making use of the flutter_riverpod package for state management instead of the proposed flutter_bloc proposed by Celo composer.
    So remove the flutter_block package from your dependencies and run the commands below
  flutter pub add flutter_riverpod
  flutter pub upgrade
  • Replace the code in your main.dart file with the code below as this serves as the root of our flutter application:
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_celo_composer/configs/themes.dart';
import 'package:flutter_celo_composer/module/services/secure_storage.dart';
import 'package:flutter_celo_composer/module/view/screens/authentication_screen.dart';
import 'package:flutter_celo_composer/module/view/screens/home_page.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:stack_trace/stack_trace.dart' as stack_trace;


void main() async {
  await dotenv.load(fileName: '.env');
  runApp(const MyApp());
  FlutterError.demangleStackTrace = (StackTrace stack) {
    if (stack is stack_trace.Trace) return stack.vmTrace;
    if (stack is stack_trace.Chain) return stack.toTrace().vmTrace;
    return stack;
  };
}


class MyApp extends StatelessWidget {
  const MyApp({
    Key? key,
  }) : super(key: key);


  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      child: MaterialApp(
        debugShowCheckedModeBanner: false,
        theme: buildDefaultTheme(context),
        home: const MyHomePage(),
      ),
    );
  }
}


class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});


  @override
  State<MyHomePage> createState() => _MyHomePageState();
}


class _MyHomePageState extends State<MyHomePage> {
  String? isCreated;


  @override
  void initState() {
    super.initState();
    SchedulerBinding.instance.addPostFrameCallback((_) async {
      isCreated = await UserSecureStorage().getCreatedBoolean();
      setState(() {});
    });
  }


  @override
  Widget build(BuildContext context) {
    return isCreated != null && isCreated == 'true'
        ? const HomePage()
        : const AuthenticationScreen();
  }
}
  • We will be creating five(5) folders inside the module folder, these include the VIEW folder that contains the User Interfaces (Screens) of the app and the widgets, the VIEW_MODEL which contains the providers and the controllers of the application, the MODEL folder which contains the repository folder for interacting with libraries, packages and services, the CUSTOM_WIDGETS folder that contains all the custom widgets used in the app and finally we’ll have the SERVICES folder which will contain the smart contract interactions, the abi json file and other services needed in the app.

The project structure should look like this:

Users will not be redirected to any wallet app to process any payment as the purpose of the tutorial is to show users how to create a Celo decentralized identity using the Celo did prefix: did:celo:<identifier>, hashing it on the smart contract deployed to Celo blockchain and the mapping it to the address of the user with the unique identifier of the user. This makes any activity on the DApp secure as users now have a personalized decentralized identity and can now be authenticated before proceeding with any crucial activity on the DApp.

Registration Screen

This screen contains the user wallet address field which collects the user’s wallet address. It also asks the user to select the identifier for generating the Celo identity and then displays a field for the user to enter the unique identity value.

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_celo_composer/module/custom_widgets/snack_bar.dart';
import 'package:flutter_celo_composer/module/models/celo_model.dart';
import 'package:flutter_celo_composer/module/services/secure_storage.dart';
import 'package:flutter_celo_composer/module/view/screens/home_page.dart';
import 'package:flutter_celo_composer/module/view/widgets/custom_button.dart';
import 'package:flutter_celo_composer/module/view/widgets/custom_dropdown.dart';
import 'package:flutter_celo_composer/module/view/widgets/custom_textfield.dart';
import 'package:flutter_celo_composer/module/view_model/controllers/celo_controller.dart';
import 'package:flutter_celo_composer/module/view_model/providers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';


class AuthenticationScreen extends ConsumerStatefulWidget {
  const AuthenticationScreen({super.key});


  @override
  ConsumerState<AuthenticationScreen> createState() =>
      _AuthenticationScreenState();
}


class _AuthenticationScreenState extends ConsumerState<AuthenticationScreen> {
  TextEditingController walletController = TextEditingController();
  TextEditingController identifierController = TextEditingController();
  String? text;
  bool obscureText = true;


  @override
  Widget build(BuildContext context) {
    Size size = MediaQuery.of(context).size;
    return Scaffold(
      backgroundColor: Colors.white,
      body: SafeArea(
        child: SingleChildScrollView(
          child: Padding(
            padding: EdgeInsets.only(
                left: size.width * 0.04, right: size.width * 0.04, top: 30),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Row(
                  children: <Widget>[
                    Image.asset('assets/images/logo.png',
                        height: 60, width: 90),
                    const SizedBox(width: 10),
                    const Text(
                      'DECENTRALIZED ID',
                      maxLines: 1,
                      style: TextStyle(
                          fontSize: 24,
                          fontWeight: FontWeight.w800,
                          color: Colors.black54),
                    )
                  ],
                ),
                Form(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      const Text(
                        'Enter your details to generate your unique celo DID which serves as a means of authentication on this app.',
                        style: TextStyle(
                            fontSize: 14,
                            fontWeight: FontWeight.w400,
                            color: Colors.black),
                      ),
                      const SizedBox(height: 30),
                      CustomTextField(
                        text: 'Wallet Address',
                        controller: walletController,
                        hint: 'enter your wallet address',
                      ),
                      const SizedBox(height: 15),
                      DropDownCustom(
                        text: 'Unique Identifier',
                        value: text,
                        onChanged: (dynamic value) {
                          setState(() {
                            text = value;
                          });
                        },
                        hint: 'Select an identifier',
                        items: const <String>[
                          'Full Name',
                          'Password',
                          'Secret Key',
                          'Others'
                        ],
                      ),
                      const SizedBox(height: 20),
                      Container(
                        padding: const EdgeInsets.symmetric(horizontal: 10),
                        child: text != null
                            ? CustomTextField(
                                text: text ?? '',
                                obscureText: obscureText,
                                controller: identifierController,
                                suffixIcon: IconButton(
                                    onPressed: () {
                                      setState(() {
                                        obscureText = !obscureText;
                                      });
                                    },
                                    icon: Icon(
                                      !obscureText
                                          ? Icons.visibility_off_outlined
                                          : Icons.visibility_outlined,
                                      color: Colors.black26,
                                    )),
                                hint: text != 'Others'
                                    ? 'enter your ${text!.toLowerCase()}'
                                    : 'enter your unique identifier',
                              )
                            : null,
                      )
                    ],
                  ),
                ),
                const SizedBox(height: 50),
                CustomButtonWidget(
                  text: ref.watch(celoProvider).createStatus == Status.loading
                      ? const CircularProgressIndicator(color: Colors.white)
                      : const Text(
                          'Generate CELO DID',
                          style: TextStyle(
                              fontSize: 18,
                              fontWeight: FontWeight.w500,
                              color: Colors.white),
                        ),
                  onPressed: () async {
                    if (walletController.text.trim().isEmpty || text == null) {
                      CustomSnackbar.responseSnackbar(context, Colors.redAccent,
                          'Fill the required fields..');
                      return;
                    }
                    CeloDIDModel data = CeloDIDModel(
                        address: walletController.text.trim(),
                        identifier: identifierController.text.trim());
                    await ref.read(celoProvider).createCeloDID(data, context);
                  },
                ),
                const SizedBox(height: 10),
                Center(
                  child: RichText(
                      textAlign: TextAlign.center,
                      text: TextSpan(
                          text: 'Already registered ? ',
                          style: const TextStyle(
                              fontSize: 14,
                              fontWeight: FontWeight.w400,
                              color: Colors.black),
                          children: <TextSpan>[
                            TextSpan(
                                text: 'Proceed to Home',
                                style: const TextStyle(
                                    fontSize: 14,
                                    fontWeight: FontWeight.w500,
                                    color: Colors.black54),
                                recognizer: TapGestureRecognizer()
                                  ..onTap = () async {
                                    await UserSecureStorage()
                                        .setCreatedBoolean();
                                    if (!mounted) return;
                                    Navigator.pushReplacement(
                                        context,
                                        MaterialPageRoute<dynamic>(
                                            builder: (_) => const HomePage()));
                                  })
                          ])),
                )
              ],
            ),
          ),
        ),
      ),
    );
  }


  @override
  void dispose() {
    walletController.dispose();
    identifierController.dispose();
    super.dispose();
  }
}
onPressed: () async {
                    if (walletController.text.trim().isEmpty || text == null) {
                      CustomSnackbar.responseSnackbar(context, Colors.redAccent,
                          'Fill the required fields..');
                      return;
                    }
                    CeloDIDModel data = CeloDIDModel(
                        address: walletController.text.trim(),
                        identifier: identifierController.text.trim());
                    await ref.read(celoProvider).createCeloDID(data, context);
                  },

The onPressed properties of the button on the screen holds the series of event that happens when the user clicks the button. It firstly checks if the user fills the fields and shows an error snackbar if the fields are not completely filled. Then it creates a variable of type CeloDIDModel which assigns the required values to its properties.
Finally, the createCeloDID function of the controller class is called which is shown below:

Future<dynamic> createCeloDID(CeloDIDModel data, dynamic context) async {
    try {
      String did = addCeloPrefix(data.identifier ?? '');
      createStatus =
          createStatus != Status.loading ? Status.loading : Status.done;
      notifyListeners();
      if (createStatus == Status.done) return;
      String? response =
          await helper.registerDID(data.address!, did, data.identifier!);
      if (response != null) {
        print('RESPONSE =====>>>>> $response');
        createStatus = Status.done;
        notifyListeners();
        await storage.setCreatedBoolean();
        await storage.setIdentifier(data.identifier.toString());
        await storage.setUserAddress(data.address!);
        alertDialogs(
            context,
            'Celo DID Created',
            'Celo DID successfuly registered. You can now authenticate with your identifier by using the address $response',
            () => Navigator.pushAndRemoveUntil(
                context,
                MaterialPageRoute<dynamic>(
                    builder: (dynamic context) => const HomePage()),
                (route) => false));
      } else {
        createStatus = Status.done;
        CustomSnackbar.responseSnackbar(
            context, Colors.redAccent, 'unable to register did');
      }
      notifyListeners();
    } catch (e) {
      createStatus = Status.done;
      notifyListeners();
      CustomSnackbar.responseSnackbar(context, Colors.redAccent, e.toString());
      debugPrint(e.toString());
    }
  }
  • The addCeloPrefix function called is a function that adds the celo unique did to the identifier entered by the user. It accepts the identifier string, add the celo did prefix and returns the final string.
String addCeloPrefix(String identifier) {
  String prefix = 'did:celo:';
  String did = prefix + identifier.trim().toLowerCase();
  return did;
}
  • From the createCeloDID function above, the function then calls the registerDID function of the Celo Web3 class situated in the SERVICES folder. Once the user is successfully registered, the did returned will then be displayed to the user through the alert dialog on the screen.
 Future<dynamic> registerDID(
      String address, String did, String identifier) async {
    final DeployedContract contract = await deployedCeloContract;
    final EthereumAddress ethAddress = EthereumAddress.fromHex(address);
    final ContractFunction registerDIDFunction =
        contract.function('registerDID');
    final response = await client.sendTransaction(
      credentials,
      Transaction.callContract(
        contract: contract,
        function: registerDIDFunction,
        parameters: <dynamic>[ethAddress, did, identifier],
      ),
      chainId: 44787,
    );
    final dynamic receipt = await awaitResponse(response);
    return receipt;
  }
  • The registerDID function of the CeloWeb3Helper class sends a transaction to the deployed contract on the celo blockchain passing the required parameters and then returns a transaction receipt showing a successful transaction.

Below are the basic contents of the CeloWeb3Helper class file which also contains the other methods/functions like verifyDID, fetchDID, changeDID, and authenticateUser.

import 'dart:convert';


import 'package:convert/convert.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart';
import 'package:web3dart/web3dart.dart';


final Web3Client client =
    Web3Client('https://alfajores-forno.celo-testnet.org', Client());


String? privateKey = dotenv.env['PRIVATE_KEY']; 
final String? contractAddress = dotenv.env['CONTRACT_ADDRESS'];
// ensure the PRIVATE_KEY and CONTRACT_ADDRESS variables are created inside the .env file in your project folder


class CeloWeb3Helper {


  /// Get deployed CeloDIDRegistry contract
  Future<DeployedContract> get deployedCeloContract async {
    const String abiDirectory = 'lib/module/services/did.abi.json';
    String contractABI = await rootBundle.loadString(abiDirectory);


    final DeployedContract contract = DeployedContract(
      ContractAbi.fromJson(contractABI, 'CeloDIDRegistry'),
      EthereumAddress.fromHex(contractAddress!),
    );


    return contract;
  }


  final EthPrivateKey credentials = EthPrivateKey.fromHex(privateKey ?? '');


  Future<dynamic> registerDID(
      String address, String did, String identifier) async {
    final DeployedContract contract = await deployedCeloContract;
    final EthereumAddress ethAddress = EthereumAddress.fromHex(address);
    final ContractFunction registerDIDFunction =
        contract.function('registerDID');
    final response = await client.sendTransaction(
      credentials,
      Transaction.callContract(
        contract: contract,
        function: registerDIDFunction,
        parameters: <dynamic>[ethAddress, did, identifier],
      ),
      chainId: 44787,
    );
    final dynamic receipt = await awaitResponse(response);
    return receipt;
  }


  Future<dynamic> fetchDID(String address, String identifier) async {
    final DeployedContract contract = await deployedCeloContract;
    final EthereumAddress ethAddress = EthereumAddress.fromHex(address);
    final fetchDIDFunction = contract.function('getDIDByAddress');
    final response = await client.call(
      contract: contract,
      function: fetchDIDFunction,
      params: <dynamic>[ethAddress, identifier],
    );
    Uint8List bytes = Uint8List.fromList(response[0].toList());
    String byte32 = '0x${hex.encode(bytes)}';


    return byte32;
  }


  Future<dynamic> authenticateUser(String address, String celoDid) async {
    final DeployedContract contract = await deployedCeloContract;
    final EthereumAddress ethAddress = EthereumAddress.fromHex(address);
    List<int> bytes = hex.decode(celoDid.replaceAll('0x', ''));
    Uint8List did = Uint8List.fromList(bytes);
    final authenticateUser = contract.function('authenticateUser');
    final response = await client.call(
      contract: contract,
      function: authenticateUser,
      params: <dynamic>[ethAddress, did],
    );
    print("response ====>> $response");
    return response[0];
  }


  Future<dynamic> changeDID(String address, String oldDid, String newDid,
      String identifier, String newIdentifier) async {
        // check github repo for the complete code
      }


  Future<dynamic> verifyDID(String address, String did, String identifier ){
      // check the github repo for full code
}


Future<dynamic> awaitResponse(dynamic response) async {
  int count = 0;
  while (true) {
    final TransactionReceipt? receipt =
        await client.getTransactionReceipt(response);
    if (receipt != null) {
      print('receipt ===>> $receipt');
      return receipt.logs[0].data;
    }
    // Wait for a while before polling again
    await Future<dynamic>.delayed(const Duration(seconds: 1));
    if (count == 6) {
      return null;
    } else {
      count++;
    }
  }
  1. The deployedCeloContract returns the DeployedContract when called inside the function.

  2. The registerDID function as earlier explained, calls the registerDID function of the smart contract and returns the TransactionReceipt.

  1. The fetchDID function accepts the address, and the user’s unique identifier, then converts the address to EthereumAddress which then calls the smart contract function getDIDByAddress. The response is converted to Uint8List and then encoded to bytes. The string interpolation adds 0x to the encoded Uint8List and then returns the string which is the user’s DID.

  1. The authenticateUser function is called when the user is about to make a transaction on the DApp or communicate with any smart contract that requires authentication. It takes the user’s address and did and then confirms the address and did are mapped on the contract then returns true.

This should redirect the user to their wallet for payment or to the smart contract screen for further usage. But for the purpose of this tutorial, we have ensured each user has their Celo decentralized identity and can use the identity to interact with smart contract or other functionality you might want to use it for.

  1. The changeDID function takes the address, the current and new identifiers, the current did and new interpolated did, calls the changeDID function of the contract and then changes the did and unique identifier.

  1. The verifyDID function verifies the user by changing the isVerified property of the Identity struct mapped to the user address

  1. The awaitResponse function takes the response variable from each transaction and ensures the transaction is processed and receipt is generated before returning the receipt.

Conclusion

Through this tutorial, we’ve shown how to implement key functionalities such as registering a DID, changing a DID, verifying a DID, and getting a DID by an address. I’ve taken you through each step, from understanding and setting up the smart contract on the Celo network using Celo Composer, to interacting with the contract using the web3dart package in Flutter.

This project exemplifies the potential of integrating blockchain technologies into mobile applications. It opens the door to numerous possibilities, from enhancing user experience with greater data privacy and control to paving the way for more secure transactions.

Next Steps

This tutorial just scratches the surface of what’s possible with Celo’s DID system and Flutter. The possibilities are truly limitless. So, keep exploring, keep learning, and most importantly, keep innovating. I look forward to seeing what you create next!
Also Check out the collection of Flutter tutorials for building decentralized apps on the Celo platform.

About the Author

John Igwe Eke, a proficient mobile developer and a technical writer, combines his expertise in software development with his enthusiasm for blockchain and Web3 technologies. He leverages this unique blend of skills to distill complex concepts into understandable, engaging content, thereby aiding in the democratization of these emerging technologies. Connect with me on twitter, LinkedIn, and Github.

References

  1. Flutter Documentation
  2. Celo Documentation
  3. web3dart Documentation
  4. Celo Composer
  5. Solidity by example
  6. Source Code
  7. Link to android apk
4 Likes

Heyoo @johnigwe
Nice to see you have your first piece on the Academy, although I’m guessing your not familiar with the tags.
If I’m correct this is meant to be a proposed topic right?

4 Likes

yes, it is a proposed topic awaiting approval.

3 Likes

Hi @Phenzic ,
I just changed the tag, thanks for pointing that out.

5 Likes

Anytime bro🥂 @johnigwe
Congrats again on your first proposal :clinking_glasses:

4 Likes

Congratulations on being among the highest voted proposals for this week! I’m moving this to todo so that you can get started on this project. :mortar_board: :seedling:

5 Likes

Quite an interesting piece @johnigwe
I’ll be reviewing this in 1 to 2 days

5 Likes

Alright. I will be looking forward to your review.

5 Likes

This is a great article i must say as DID is a very important factor for secure and privacy-preserving identity management, welldone bro.

8 Likes

Thank you :clinking_glasses:

4 Likes

Well written sir. Keep up the good job.

3 Likes

Hey @johnigwe
I think you’re a good flutter dev. Seeing your work here makes me feel so. I’d like to add that you should be careful using encodepacked in your contract. There has been known issue of collision.

11 Likes

Thank you for the feedback @bobelr
i have tried and tested it with different test cases and it worked as expected.

Nevertherless, could you suggest what can be used instead of encodepacked?

5 Likes

Thank you @EmiriDbest :clinking_glasses:

5 Likes

Great project. Good work

3 Likes

He used more of abi.encodePacked in his contract. I think that’s safe enough and also serves the purpose… its much more accurate and reliable for byte representation.

3 Likes

For me I mostly use convert the arguments to a byte represnsentation before invoking the hash function.
Ex.

bytes memory b = abi.encode(xz, xx, zz, someAddresses);
bytes32 hashed = keccak256(b);

Or

bytes32 hashed = keccak256(bytes(somestringsOrAnysimpleTypes));
5 Likes

This is great too. Users can also employ this method

abi.encodePacked works for me and i tested before deploying.

Thanks for pointing that out. :+1:

3 Likes