author

Kien Duong

May 21, 2022

Donation Smart Contract with Ethereum (Part 3)

In the previous post, we’ve built the logic for the donation smart contract at the backend side. In this post, we’re going to build the logic for the client side. First of all, we need to create the mockup design for the donation web app.

 

1. The mockup design

1.1. List contracts

In this screen, users will be able to see all created contracts. They can also create and become the manager of a new one.

 

donation smart contract 16

 

donation smart contract 17

 

2. Contract detail

Showing the contract detail & list requests. User can contribute to become a contributor & approve a request. Besides, manager can create a new request & finish a created request when it has enough approvals.

donation smart contract 18

 

donation smart contract 19

 

donation smart contract 20

2. Frontend

Using the sample Angular project to build the frontend for this application. Let’s go over some of the main parts.

2.1. Services

We need to create two main services: ApiService (connect to get the deployed contract information), Web3Service (using deployed contract information to connect to Rinkeby network via MetaMask).

    // api.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

// Services
import { ApiBaseService } from '../api-base/api-base.service';

// Env
import { environment } from '../../../environments/environment';

@Injectable({
  providedIn: 'root'
})
export class ApiService extends ApiBaseService {

  private apiBase: string;

  constructor(
    private http: HttpClient,
  ) {
    super();
    this.apiBase = environment.apiBase;
  }

  contract(): Observable<any> {
    const url = this.makeUrl(`${this.apiBase}/contract`);
    return this.http.get(url);
  }

}

  
    // web3.service.ts
import { Injectable } from '@angular/core';
import Web3 from 'web3';
import { Store } from '@ngrx/store';

// States
import { selectContractData } from '../../states/contract';

// Interfaces
import { IContractInfo } from '../../interfaces';

declare let window: any;

@Injectable({
  providedIn: 'root'
})
export class Web3Service {

  private web3: Web3;
  private factory: any;
  private contract: IContractInfo;

  constructor(
    private store: Store,
  ) {
    this.store.select(selectContractData).subscribe(res => {
      if (res) {
        this.contract = res;
        this.loadWeb3();
        this.loadFactory();
      }
    });
  }

  public get web3Instance() {
    return this.web3;
  }

  public get factoryInstance() {
    return this.factory;
  }

  public donationInstance(address: string) {
    const abi = this.contract.donation?.abi;
    return new this.web3.eth.Contract(JSON.parse(JSON.stringify(abi)), address);
  }

  private loadWeb3() {
    if (window && window.ethereum) {
      window.ethereum.request({ method: 'eth_requestAccounts' });
      this.web3 = new Web3(window.ethereum);
    } else {
      console.log('Can not detect Etherem. Please install Metamask.');
    }
  }

  private loadFactory() {
    const abi = this.contract.factory?.abi;
    const address = this.contract.address;
    this.factory = new this.web3.eth.Contract(JSON.parse(JSON.stringify(abi)), address);
  }

}

  

  • loadWeb3() will request the permission from MetaMask to access to your accounts.
  • loadFactory() when we already have the permission to access to the MetaMask (web3), this function will use the deployed contract information (address & factory abi) to connect to Factory instance on the Rinkeby network from frontend.
  • donationInstance(address: string) the same way to get Factory instance, this function will be used to get Donation instance. Because we have many donation contracts on this platform so we have to pass the deployed Donation address to get the data.

2.2. List contracts screen

Come back the solidity code, you can see that we already have a function to get deployed donation contracts getDeployedDonations() in contract Factory. This function returns list deployed addresses. In order to get list donation addresses on the frontend, we must use factory instance to call getDeployedDonations method. this.web3Service.factoryInstance.methods.getDeployedDonations().call()

    // contract.component.ts
import { Component, OnInit, ViewChild, TemplateRef, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { Store } from '@ngrx/store';
import { NgbModalRef, NgbModal } from '@ng-bootstrap/ng-bootstrap';

// Services
import { Web3Service } from '../../services/web3/web3.service';

// States
import { selectEventData } from '../../states/event';

// Interfaces
import { ITableColumn, IListContractItem } from '../../interfaces';

// Constants
import { REFRESH_LIST_CONTRACTS } from '../../constants';

@Component({
  selector: 'app-contract',
  templateUrl: './contract.component.html',
  styleUrls: ['./contract.component.scss']
})
export class ContractComponent implements OnInit, OnDestroy {

  @ViewChild('createContractModal') private createContractModal: TemplateRef<ContractComponent>

  public contractTableColumns: ITableColumn[] = [];
  public listContracts: IListContractItem[] = [];
  public loading: boolean = false;

  private subscriptions: Subscription[] = [];
  private createContractModalRef: NgbModalRef;

  constructor(
    private store: Store,
    private web3Service: Web3Service,
    private modalService: NgbModal,
  ) {
    this.subscriptions.push(
      this.store.select(selectEventData).subscribe(res => {
        if (res) {
          switch (res.name) {
            case REFRESH_LIST_CONTRACTS: {
              this.getListContracts();
              break;
            }
          }
        }
      })
    );
  }

  ngOnInit() {
    this.contractTableColumns = [
      { field: 'id', header: 'ID' },
      { field: 'address', header: 'Address' },
      { field: '', header: '' }
    ];

    this.getListContracts();
  }

  public createContract() {
    this.createContractModalRef = this.modalService.open(this.createContractModal);
  }

  public closeModal() {
    if (this.createContractModalRef) {
      this.createContractModalRef.close();
    }
  }

  private async getListContracts() {
    try {
      this.loading = true;
      const listContracts = await this.web3Service.factoryInstance.methods.getDeployedDonations().call();
      if (listContracts && listContracts.length) {
        let i = 0;
        for (const item of listContracts) {
          i++;
          const contractItem: IListContractItem = {
            id: i,
            address: item,
          }
          this.listContracts.push(contractItem);
        }
      }
    } catch (e) {
      console.log(e);
    } finally {
      this.loading = false;
    }
  }

  ngOnDestroy() {
    if (this.subscriptions.length) {
      this.subscriptions.map(sub => {
        if (sub) {
          sub.unsubscribe();
        }
      });
    }
  }

}

  

As the mockup design, we also need to create the logic for creating a new contract on this screen. When user clicks to create contract button, the system will open a modal that allows user to submit the minimum contribution value. Based on the solidity code, we must write the logic to call to createDonation method of Factory contract this.web3Service.factoryInstance.methods.createDonation(minimumContribution).send({ from: accountAddress });

When calling to the createDonation method, we must pass the current account address. It means that the current account will become the manager of this contract. Every time the send function is called, MetaMask will open the confirm popup to ask the current user if he agree to pay some gas for this action.

    // create-contract.component.ts
import { Component, OnInit, Output, EventEmitter } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MessageService } from 'primeng/api';

// Services
import { Web3Service } from '../../services/web3/web3.service';
import { HelperService } from '../../services/helper/helper.service';

// Validation
import { CustomValidation } from '../../validation';

// Constants
import { REFRESH_LIST_CONTRACTS } from '../../constants';

@Component({
  selector: 'app-create-contract',
  templateUrl: './create-contract.component.html',
  styleUrls: ['./create-contract.component.scss']
})
export class CreateContractComponent implements OnInit {

  @Output() cancelCb = new EventEmitter<any>();

  public form: FormGroup;
  public loading: boolean = false;

  constructor(
    private fb: FormBuilder,
    private web3Service: Web3Service,
    private messageService: MessageService,
    private helperService: HelperService,
  ) { }

  ngOnInit(): void {
    this.form = this.fb.group({
      minimumContribution: ['', [Validators.required, CustomValidation.number]],
    });
  }

  public async onSubmit() {
    if (this.form.invalid) {
      return;
    }

    this.loading = true;

    try {
      const accounts = await this.web3Service.web3Instance.eth.getAccounts();
      await this.web3Service.factoryInstance.methods
        .createDonation(this.form.value.minimumContribution)
        .send({
          from: accounts[0],
        });

      this.messageService.add({
        severity: 'success',
        detail: 'Create contract successfully'
      });
      this.helperService.emitEvent(REFRESH_LIST_CONTRACTS);
    } catch (e) {
      console.log(e);
      this.messageService.add({
        severity: 'error',
        detail: 'Something went wrong'
      });
    } finally {
      this.cancel();
      this.loading = false;
    }
  }

  public cancel() {
    this.cancelCb.emit();
  }

}

  

2.3. Contract detail screen

this.web3Service.donationInstance(this.address) is used to get the donation instance. The donation address param will be got from url. After that, using getDetail method of contract Donation to get the contract information donation.methods.getDetail().call();

The getDetail method returns an object that contains the manager address, minimum contribution value, number of requests, number of contributors & the contract balance.

    // contract-detail.component.ts
import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { NgbModalRef, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Store } from '@ngrx/store';

// Services
import { Web3Service } from '../../../services/web3/web3.service';

// Interfaces
import { IContractDetail, IContractRequest, ITableColumn } from '../../../interfaces';

// States
import { selectEventData } from 'src/app/states/event';

// Constants
import { REFRESH_LIST_REQUESTS } from 'src/app/constants';

@Component({
  selector: 'app-contract-detail',
  templateUrl: './contract-detail.component.html',
  styleUrls: ['./contract-detail.component.scss']
})
export class ContractDetailComponent implements OnInit, OnDestroy {

  @ViewChild('contributionModal') private contributionModal: TemplateRef<ContractDetailComponent>
  @ViewChild('createRequestModal') private createRequestModal: TemplateRef<ContractDetailComponent>
  @ViewChild('confirmModal') private confirmModal: TemplateRef<ContractDetailComponent>

  public contractDetail: IContractDetail;
  public address: any;
  public isManager: boolean = false;
  public isContributor: boolean = false;
  public requests: IContractRequest[] = [];
  public requestTableColumns: ITableColumn[] = [];
  public message: string = '';
  public confirmType: string = '';
  public requestIndex: any;
  public confirmLoading: boolean = false;

  private subscriptions: Subscription[] = [];
  private contributionModalRef: NgbModalRef;
  private createRequestModalRef: NgbModalRef;
  private confirmModalRef: NgbModalRef;

  constructor(
    private activatedRoute: ActivatedRoute,
    private web3Service: Web3Service,
    private modalService: NgbModal,
    private store: Store,
  ) {
    this.address = this.activatedRoute.snapshot.paramMap.get('address');

    this.subscriptions.push(
      this.store.select(selectEventData).subscribe(res => {
        if (res) {
          switch (res.name) {
            case REFRESH_LIST_REQUESTS: {
              this.getContractDetail();
              break;
            }
          }
        }
      })
    );
  }

  ngOnInit(): void {
    this.requestTableColumns = [
      { field: 'description', header: 'Description' },
      { field: 'value', header: 'Value (WEI)' },
      { field: 'recipient', header: 'Recipient' },
      { field: 'approvalsCount', header: 'Number of approvals' },
    ];

    this.getContractDetail();
  }

  public openContributionModal() {
    this.contributionModalRef = this.modalService.open(this.contributionModal);
  }

  public openCreateRequestModal() {
    this.createRequestModalRef = this.modalService.open(this.createRequestModal);
  }

  public closeContributionModal() {
    if (this.contributionModalRef) {
      this.contributionModalRef.close();
    }
  }

  public closeCreateRequestModal() {
    if (this.createRequestModalRef) {
      this.createRequestModalRef.close();
    }
  }

  public openConfirmationModal(index: any, type: string) {
    this.confirmModalRef = this.modalService.open(this.confirmModal);
    this.requestIndex = index;

    switch (type) {
      case 'approve': {
        this.confirmType = 'approve';
        this.message = 'Do you want to approve this request?';
        break;
      }
      case 'finish': {
        this.confirmType = 'finish';
        this.message = 'Do you want to finish this request?';
        break;
      }
    }
  }

  public handleOk() {
    switch (this.confirmType) {
      case 'approve': {
        this.approveRequest();
        break;
      }
      case 'finish': {
        this.finishRequest();
        break;
      }
    }
  }

  public handleCancel() {
    this.closeConfirmModal();
  }

  private closeConfirmModal() {
    if (this.confirmModalRef) {
      this.confirmModalRef.close();
      this.confirmType = '';
      this.message = '';
      this.requestIndex = null;
    }
  }

  private async approveRequest() {
    try {
      this.confirmLoading = true;
      const donation = this.web3Service.donationInstance(this.address);
      const accounts = await this.web3Service.web3Instance.eth.getAccounts();
      await donation.methods.approveRequest(parseInt(this.requestIndex)).send({
        from: accounts[0],
      });
      this.getContractDetail();
    } catch (e) {
      console.log(e);
    } finally {
      this.confirmLoading = false;
      this.closeConfirmModal();
    }
  }

  private async finishRequest() {
    try {
      this.confirmLoading = true;
      const donation = this.web3Service.donationInstance(this.address);
      const accounts = await this.web3Service.web3Instance.eth.getAccounts();
      await donation.methods.finishRequest(parseInt(this.requestIndex)).send({
        from: accounts[0],
      });
      this.getContractDetail();
    } catch (e) {
      console.log(e);
    } finally {
      this.confirmLoading = false;
      this.closeConfirmModal();
    }
  }

  private async getContractDetail() {
    try {
      const donation = this.web3Service.donationInstance(this.address);
      const result = await donation.methods.getDetail().call();

      this.contractDetail = {
        managerAddress: result[0],
        minimumContribution: result[1],
        requests: result[2],
        contributers: result[3],
        balance: result[4],
      }

      const accounts = await this.web3Service.web3Instance.eth.getAccounts();

      this.isContributor = await donation.methods.approvers(accounts[0]).call();

      if (accounts[0] === this.contractDetail.managerAddress) {
        this.isManager = true;
      }

      await this.getListRequests();
    } catch (e) {
      console.log(e);
    }
  }

  private async getListRequests() {
    try {
      if (this.contractDetail.requests) {
        const requests = [];

        const countRequests = parseInt(this.contractDetail.requests, 10);
        const donation = this.web3Service.donationInstance(this.address);

        for (let i = 0; i < countRequests; i++) {
          const req = await donation.methods.requests(i).call();
          const data: IContractRequest = {
            approvalsCount: req.approvalsCount,
            complete: req.complete,
            description: req.description,
            recipient: req.recipient,
            value: req.value,
            completable: parseInt(req.approvalsCount) > (parseInt(this.contractDetail.contributers) / 2)
          }
          requests.push(data);
        }

        this.requests = [...requests];
      }
    } catch (e) {
      console.log(e);
    }
  }

  ngOnDestroy() {
    if (this.subscriptions.length) {
      this.subscriptions.map(sub => {
        if (sub) {
          sub.unsubscribe();
        }
      });
    }
  }

}

  

In order to see the list request of this donation contract, the current user must be a contributor of this contract. It means that the current user must donate some money first. Using contribute method of donation contract donation.methods.contribute().send({ from: accountAddress, value: donation }); to donate the money.

When the current user is already the contributor, just need to access to the requests value (mapping(uint => Request) public requests) to get list requests by using donation.methods.requests(i).call(); i is the index of request in the mapping data.

For the create request button, it should be only shown when the current user is the manager of the contract. It’s quite easy to check because we already have the manager address inside the contract detail. Using createRequest method donation.methods.createRequest(description, value, recipient).send({ from: accountAddress }); to create a new request.

  • description is the content of request.
  • value is the money that manager wants to send to the recipient from the contract balance.
  • recipient is the address of the recipient.

    // contribute.component.ts
import { Component, OnInit, Output, EventEmitter } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { MessageService } from 'primeng/api';

// Services
import { Web3Service } from '../../services/web3/web3.service';
import { HelperService } from '../../services/helper/helper.service';

// Validation
import { CustomValidation } from '../../validation';

// Constants
import { REFRESH_LIST_REQUESTS } from 'src/app/constants';

@Component({
  selector: 'app-contribute',
  templateUrl: './contribute.component.html',
  styleUrls: ['./contribute.component.scss']
})
export class ContributeComponent implements OnInit {

  @Output() cancelCb = new EventEmitter<any>();

  public form: FormGroup;
  public loading: boolean = false;

  private address: any;

  constructor(
    private fb: FormBuilder,
    private web3Service: Web3Service,
    private activatedRoute: ActivatedRoute,
    private messageService: MessageService,
    private helperService: HelperService,
  ) {
    this.address = this.activatedRoute.snapshot.paramMap.get('address');
  }

  ngOnInit(): void {
    this.form = this.fb.group({
      donation: ['', [Validators.required, CustomValidation.number]],
    });
  }

  public async onSubmit() {
    if (this.form.invalid) {
      return;
    }

    this.loading = true;

    try {
      const accounts = await this.web3Service.web3Instance.eth.getAccounts();
      const donationInstance = this.web3Service.donationInstance(this.address);

      const { donation } = this.form.value;

      await donationInstance.methods.contribute().send({
        from: accounts[0],
        value: donation,
      });

      this.messageService.add({
        severity: 'success',
        detail: 'Create contract successfully'
      });
      this.helperService.emitEvent(REFRESH_LIST_REQUESTS);
    } catch (e) {
      console.log(e);
      this.messageService.add({
        severity: 'error',
        detail: 'Something went wrong'
      });
    } finally {
      this.loading = false;
      this.cancel();
    }
  }

  public cancel() {
    this.cancelCb.emit();
  }

}

  
    // create-request.component.ts
import { Component, OnInit, Output, EventEmitter } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { MessageService } from 'primeng/api';

// Services
import { Web3Service } from 'src/app/services/web3/web3.service';
import { HelperService } from 'src/app/services/helper/helper.service';

// Validation
import { CustomValidation } from '../../validation';

// Constants
import { REFRESH_LIST_REQUESTS } from 'src/app/constants';

@Component({
  selector: 'app-create-request',
  templateUrl: './create-request.component.html',
  styleUrls: ['./create-request.component.scss']
})
export class CreateRequestComponent implements OnInit {

  @Output() cancelCb = new EventEmitter<any>();

  public form: FormGroup;
  public loading: boolean = false;

  private address: any;

  constructor(
    private fb: FormBuilder,
    private web3Service: Web3Service,
    private activatedRoute: ActivatedRoute,
    private messageService: MessageService,
    private helperService: HelperService,
  ) {
    this.address = this.activatedRoute.snapshot.paramMap.get('address');
  }

  ngOnInit(): void {
    this.form = this.fb.group({
      description: ['', [Validators.required]],
      value: ['', [Validators.required, CustomValidation.number]],
      recipient: ['', [Validators.required]]
    });
  }

  public async onSubmit() {
    if (this.form.invalid) {
      return;
    }

    this.loading = true;

    try {
      const accounts = await this.web3Service.web3Instance.eth.getAccounts();
      const donation = this.web3Service.donationInstance(this.address);

      const { description, value, recipient } = this.form.value;

      await donation.methods
        .createRequest(description, value, recipient)
        .send({ from: accounts[0] });

      this.messageService.add({
        severity: 'success',
        detail: 'Create request successfully'
      });
      this.helperService.emitEvent(REFRESH_LIST_REQUESTS);
    } catch (e) {
      console.log(e);
      this.messageService.add({
        severity: 'error',
        detail: 'Something went wrong'
      });
    } finally {
      this.loading = false;
      this.cancel();;
    }
  }

  public cancel() {
    this.cancelCb.emit();
  }

}

  

In the list requests table, we have two buttons on each request: approve & finish buttons. The approve button is shown for all contributors to approve a request by donation.methods.approveRequest(requestIndex).send({ from: accountAddress }); The finish button is only shown for the manager to finish the request, sending the money to the recipient by donation.methods.finishRequest(requestIndex).send({ from: accountAddress });

Both two methods must pay some gas to be executed. For now, we’ve gone through the main functions of the donation application. You can also review two previous parts at:

Get the source code of the web app at this Github link.

Recent Blogs