We use cookies and other tracking technologies to improve your browsing experience on our site, analyze site traffic, and understand where our audience is coming from. To find out more, please read our privacy policy.

By choosing 'I Accept', you consent to our use of cookies and other tracking technologies.

We use cookies and other tracking technologies to improve your browsing experience on our site, analyze site traffic, and understand where our audience is coming from. To find out more, please read our privacy policy.

By choosing 'I Accept', you consent to our use of cookies and other tracking technologies. Less

We use cookies and other tracking technologies... More

Login or register
to publish this job!

Login or register
to save this job!

Login or register
to save interesting jobs!

Login or register
to get access to all your job applications!

Login or register to start contributing with an article!

Login or register
to see more jobs from this company!

Login or register
to boost this post!

Show some love to the author of this blog by giving their post some rocket fuel 🚀.

Login or register to search for your ideal job!

Login or register to start working on this issue!

Login or register
to save articles!

Login to see the application

Engineers who find a new job through WorksHub average a 15% increase in salary 🚀

You will be redirected back to this page right after signin

Blog hero image

Basics of Ethereum Decentralized Applications (DApps)

Nemanja Grubor 20 September, 2021 | 12 min read

Introduction

In this article, we will be going to talk about the basics of Ethereum decentralized applications (DApps). The article is for people who would like to learn the basics of Ethereum DApps - theory, and implementation-wise.

Note that the project implementation is done on Windows.

Ethereum Decentralized Applications (DApps) - Basic Theory

Ethereum blockchain provides computation and storage capabilities using smart contracts. From there, Ethereum DApps can deploy smart contracts to use the capabilities provided by Ethereum to implement business logic.

Three architecture types are adopted by Ethereum DApps:

  • Direct
  • Indirect
  • Mixed

For DApps of the direct architecture, the client directly interacts with smart contracts deployed on the Ethereum. DApps of the indirect architecture have back-end services running on a centralized server, and the client interacts with smart contracts through the server. DApps of the mixed architecture combines the preceding two architectures where the client interacts with smart contracts both directly and indirectly through a back-end server.

DApps are divided into 17 categories:

  1. Exchanges
  2. Games
  3. Finance
  4. Gambling
  5. Development
  6. Storage
  7. High-risk
  8. Wallet
  9. Governance
  10. Property
  11. Identity
  12. Media
  13. Social
  14. Security
  15. Energy
  16. Insurance
  17. Health

The cost of smart contracts in DApps includes two parts:

  • Deployment cost
  • Execution cost

Deployments and executions are done as transactions, which cost gas. Gas is paid with Ethers, and the amount of gas used is a measurement of the complexity of contract execution. An account sends some gas in contract execution and then gets the gas left when the contract execution is confirmed. If the transaction has used all the gas sent from the initiator, the account receives an error information ”out of gas” and loses all gas it sends.

To lower the costs of deployments and executions is important. In the Ethereum blockchain, the total gas of a block is limited. A complex smart contract may cost too much gas so that it cannot be deployed, i.e., the block will not contain the transaction. In addition, the higher the contract execution costs are, the lower the throughput of contract executions, and the longer users wait for confirmations of executions.

Ethereum DApp Example with an Implementation

Now, as we have learned the basic DApp theory, we will be going to implement an example of it.

Task

Implement a DApp for children support organization donations, based on Ethereum blockchain.

Functionalities:

  • Display of a current donor
  • Input the amount of DAI tokens to be donated. DAI is a cryptocurrency that is the closest to US Dollar.
  • Display of a donation balance and a current donor's balance

Technology Stack

For this project, we will be going to use the following technology stack:

  • Ganache
  • MetaMask
  • Node.js
  • Solidity
  • Truffle
  • React.js
  • Web3.js

Tools

Ganache

Ganache (Truffle Suite) is a personal blockchain for Ethereum application development. Ganache comes in two flavors: UI and CLI. It is more user-friendly to use Ganache UI. All versions of Ganache are available for Windows, Mac, and Linux.

Note: Ganache can't be run on 32-bit architecture (at least for Windows).

MetaMask

MetaMask is a browser extension (tested on Firefox and Chrome browsers). It has everything you need to manage your digital assets. Also, it provides a secure way to connect to blockchain-based applications.

Node.js

Node.js is an asynchronous event-driven JavaScript runtime.

After installing Node.js, we will be going to install the following tools and dependencies:

  • Solidity compiler
  • Truffle
  • React.js
  • Web3.js
Solidity Compiler

Use the following code in the command line to install and check the Solidity compiler:

npm install -g solc
solcjs --version
Truffle, React.js, Web3.js

We will need a truffle-config.js file, which is a configuration file.

truffle-config.js file is given as follows:

module.exports = {
  networks: {
    development: {
      host: "127.0.0.1",
      port: 7545,
      network_id: "*" // Match any network id
    },
  },
  contracts_directory: './src/contracts/',
  contracts_build_directory: './src/abis/',
  compilers: {
    solc: {
      optimizer: {
        enabled: true,
        runs: 200
      },
      evmVersion: "petersburg"
    }
  }
}

Then, we will be going to install Truffle, React.js, and Web3.js via the following package.json file:

{
  "name": "dapp",
  "version": "1.0.0",
  "description": "children-support-dapp",
  "main": "truffle-config.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "react-scripts start"
  },
  "author": "<your email address>",
  "license": "ISC",
  "browserslist": [
    ">0.2%",
    "not dead",
    "not ie <= 11",
    "not op_mini all"
  ],
  "dependencies": {
    "@truffle/hdwallet-provider": "^1.5.0",
    "babel-polyfill": "6.26.0",
    "babel-preset-env": "1.7.0",
    "babel-preset-es2015": "6.24.1",
    "babel-preset-stage-2": "6.24.1",
    "babel-preset-stage-3": "6.24.1",
    "babel-register": "6.26.0",
    "bootstrap": "4.3.1",
    "chai": "4.2.0",
    "chai-as-promised": "7.1.1",
    "chai-bignumber": "3.0.0",
    "ganache-cli": "^6.12.2",
    "identicon.js": "^2.3.3",
    "parity": "^0.2.7",
    "react": "16.8.4",
    "react-bootstrap": "1.0.0-beta.5",
    "react-dom": "16.8.4",
    "react-scripts": "2.1.3",
    "truffle": "5.1.39",
    "web3": "1.2.11"
  }
}

Dependency installation

Now that package.json file is created, it is possible to install dependencies from it to the projects folder. Installation is done via command line, using the following code:

npm install

This will create a new folder, called node_modules, that stores all installed dependencies.

File Structure

In this article, it is possible to see the complete file structure of this project.

project
│   migrations
└───1_initial_migration.js
└───2_deploy_contracts.js
│   node_modules    
│   public
└───favicon.ico
└───index.html
└───manifest.json
│   src
└───abis 
          └───automatically generated .json files after compiling
└───components
          └───App.css
          └───App.js
          └───Main.js
          └───Navbar.js
└───contracts
          └───DaiToken.sol
          └───Migrations.sol
          └───TokenFarm.sol
└───index.js
└───serviceWorker.js
└───dai.png
└───eth-logo.png
└───farmer.png
└───logo.png
└───token-logo.png
│   package.json
│   truffle-config.js

Solidity Smart Contracts

Simply explained, smart contracts are the back-end of a blockchain application. Here, we will be going to use Solidity language for their implementation.

Migrations Smart Contract

As the name says, Migrations.sol smart contract is used for keeping track of which migrations were done on the current network.

Here is the code:

pragma solidity >=0.4.21 <0.6.0;

contract Migrations {
  address public owner;
  uint public last_completed_migration;

  constructor() public {
    owner = msg.sender;
  }

  modifier restricted() {
    if (msg.sender == owner) _;
  }

  function setCompleted(uint completed) public restricted {
    last_completed_migration = completed;
  }

  function upgrade(address new_address) public restricted {
    Migrations upgraded = Migrations(new_address);
    upgraded.setCompleted(last_completed_migration);
  }
}

DAIToken Smart Contract

DaiToken.sol smart contract is used for the creation of the DaiToken class, which deals with DAI cryptocurrency, with basic operations (like transfer()).

Here is the code:

pragma solidity ^0.5.0;

contract DaiToken {
    string  public name = "Mock DAI Token";
    string  public symbol = "mDAI";
    uint256 public totalSupply = 1000000000000000000000000; // 1 million tokens
    uint8   public decimals = 18;

    event Transfer(
        address indexed _from,
        address indexed _to,
        uint256 _value
    );

    event Approval(
        address indexed _owner,
        address indexed _spender,
        uint256 _value
    );

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    constructor() public {
        balanceOf[msg.sender] = totalSupply;
    }

    function transfer(address _to, uint256 _value) public returns (bool success) {
        require(balanceOf[msg.sender] >= _value);
        balanceOf[msg.sender] -= _value;
        balanceOf[_to] += _value;
        emit Transfer(msg.sender, _to, _value);
        return true;
    }

    function approve(address _spender, uint256 _value) public returns (bool success) {
        allowance[msg.sender][_spender] = _value;
        emit Approval(msg.sender, _spender, _value);
        return true;
    }

    function transferFrom(address _from, address _to, uint256 _value) public returns (bool success) {
        require(_value <= balanceOf[_from]);
        require(_value <= allowance[_from][msg.sender]);
        balanceOf[_from] -= _value;
        balanceOf[_to] += _value;
        allowance[_from][msg.sender] -= _value;
        emit Transfer(_from, _to, _value);
        return true;
    }
}

TokenFarm Smart Contract

TokenFarm.sol smart contract requires the previously created DaiToken.sol smart contract. It is used for storing assets, e.g. in a bank, to be able to have some assets for donating.

Here is the code:

pragma solidity ^0.5.0;

import "./DaiToken.sol";

contract TokenFarm {
    string public name = "Dapp Token Farm";
    address public owner;
    DaiToken public daiToken;

    address[] public donors;
    mapping(address => uint) public donatingBalance;
    mapping(address => bool) public hasDonated;
    mapping(address => bool) public isDonating;

    constructor(DaiToken _daiToken) public {
        daiToken = _daiToken;
        owner = msg.sender;
    }

    function donateTokens(uint _amount) public {
        // Require amount greater than 0
        require(_amount > 0, "amount cannot be 0");

        // Trasnfer Mock Dai tokens to this contract for donating
        daiToken.transferFrom(msg.sender, address(this), _amount);

        // Update donating balance
        donatingBalance[msg.sender] = donatingBalance[msg.sender] + _amount;

        // Add user to donors array *only* if they haven't donated already
        if(!hasDonated[msg.sender]) {
            donors.push(msg.sender);
        }

        // Update donating status
        isDonating[msg.sender] = true;
        hasDonated[msg.sender] = true;
    }


}
Join our newsletter
Join over 111,000 others and get access to exclusive content, job opportunities and more!

Compiling and Migrating Smart Contracts

After creating smart contracts, you should compile them to see if there are errors. This is done via the command line. Open command prompt, and position to the abis folder.

Run the following command:

truffle compile

If there are no errors, run the following command to migrate smart contracts:

truffle migrate

Migrations

As seen in the file structure, the Migrations folder contains two .js files:

  • 1_initial_migration.js
  • 2_deploy_contracts.js

These two files serve for migrating/deployment of smart contracts.

Code for 1_initial_migration.js:

const Migrations = artifacts.require("Migrations");

module.exports = function(deployer) {
  deployer.deploy(Migrations);
};

Code for 2_deploy_contracts.js:

const DaiToken = artifacts.require('DaiToken')
const TokenFarm = artifacts.require('TokenFarm')

module.exports = async function(deployer, network, accounts) {
  // Deploy Mock DAI Token
  await deployer.deploy(DaiToken)
  const daiToken = await DaiToken.deployed()

  // Deploy TokenFarm
  await deployer.deploy(TokenFarm, /*dappToken.address,*/ daiToken.address)
  const tokenFarm = await TokenFarm.deployed()

  // Transfer all tokens to TokenFarm (1 million)
  //await dappToken.transfer(tokenFarm.address, '1000000000000000000000000')

  // Transfer 100 Mock DAI tokens to donator
  await daiToken.transfer(accounts[1], '100000000000000000000')
}

Public

Manifest

The manifest.json is a simple .json file in a website that tells the browser about a website.

Here is the code:

{
  "short_name": "Starter Kit",
  "name": "Dapp Token Farm",
  "icons": [
    {
      "src": "favicon.ico",
      "sizes": "64x64 32x32 24x24 16x16",
      "type": "image/x-icon"
    }
  ],
  "start_url": ".",
  "display": "standalone",
  "theme_color": "#000000",
  "background_color": "#ffffff"
}

Index Page

The index.html calls the previously created manifest.json file.

Here is the code:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <meta name="theme-color" content="#000000" />

    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />

    <title>Children Support Organization</title>
  </head>
  <body>
    
    <div id="root"></div>

  </body>
</html>

The favicon.ico file is an image.

src

Service Worker

A service worker is a script that a browser runs in the background (operations like push notifications and background sync).

Here is the code:

const isLocalhost = Boolean(
  window.location.hostname === 'localhost' ||
    // [::1] is the IPv6 localhost address.
    window.location.hostname === '[::1]' ||
    // 127.0.0.1/8 is considered localhost for IPv4.
    window.location.hostname.match(
      /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
    )
);

export function register(config) {
  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
    // The URL constructor is available in all browsers that support SW.
    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
    if (publicUrl.origin !== window.location.origin) {

      return;
    }

    window.addEventListener('load', () => {
      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;

      if (isLocalhost) {
        // This is running on localhost. Let's check if a service worker still exists or not.
        checkValidServiceWorker(swUrl, config);


        navigator.serviceWorker.ready.then(() => {
          console.log(
            'This web app is being served cache-first by a service ' +
              'worker.'
          );
        });
      } else {
        // Is not localhost. Just register service worker
        registerValidSW(swUrl, config);
      }
    });
  }
}

function registerValidSW(swUrl, config) {
  navigator.serviceWorker
    .register(swUrl)
    .then(registration => {
      registration.onupdatefound = () => {
        const installingWorker = registration.installing;
        if (installingWorker == null) {
          return;
        }
        installingWorker.onstatechange = () => {
          if (installingWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {

              console.log(
                'New content is available and will be used when all ' +
                  'tabs for this page are closed.'
              );

              // Execute callback
              if (config && config.onUpdate) {
                config.onUpdate(registration);
              }
            } else {

              console.log('Content is cached for offline use.');

              // Execute callback
              if (config && config.onSuccess) {
                config.onSuccess(registration);
              }
            }
          }
        };
      };
    })
    .catch(error => {
      console.error('Error during service worker registration:', error);
    });
}

function checkValidServiceWorker(swUrl, config) {
  // Check if the service worker can be found. If it can't reload the page.
  fetch(swUrl)
    .then(response => {
      // Ensure service worker exists, and that we really are getting a JS file.
      const contentType = response.headers.get('content-type');
      if (
        response.status === 404 ||
        (contentType != null && contentType.indexOf('javascript') === -1)
      ) {
        // No service worker found. Probably a different app. Reload the page.
        navigator.serviceWorker.ready.then(registration => {
          registration.unregister().then(() => {
            window.location.reload();
          });
        });
      } else {
        // Service worker found. Proceed as normal.
        registerValidSW(swUrl, config);
      }
    })
    .catch(() => {
      console.log(
        'No internet connection found. App is running in offline mode.'
      );
    });
}

export function unregister() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready.then(registration => {
      registration.unregister();
    });
  }
}

Note: .png files in the src folder are images, so we won't explain them.

Components

The components folder contains .js files, and an empty .css file, that only needs to exist.

Navigation Bar

The navigation bar contains the currently selected account, that is integrated from Ganache and MetaMask. This integration of Ganache and MetaMask will be shown later.

Here is the code for the navigation bar displaying:

import React, { Component } from 'react'
import farmer from '../farmer.png'

class Navbar extends Component {

  render() {
    return (
      <nav className="navbar navbar-dark fixed-top bg-dark flex-md-nowrap p-0 shadow">
        <a
		  href="<some URL>"
          className="navbar-brand col-sm-3 col-md-2 mr-0"
          target="_blank"
          rel="noopener noreferrer"
        >
          <img src={farmer} width="30" height="30" className="d-inline-block align-top" alt="" />
          &nbsp; Children Support Organization
        </a>

        <ul className="navbar-nav px-3">
          <li className="nav-item text-nowrap d-none d-sm-none d-sm-block">
            <small className="text-secondary">
              <small id="account">{this.props.account}</small>
            </small>
          </li>
        </ul>
      </nav>
    );
  }
}

Main

The Main.js file contains the body of the application (front-end).

It displays:

  • The amount of tokens to be donated
  • Current donor's balance
  • Submit button, for donating

Here is the code:

import React, { Component } from 'react'
import dai from '../dai.png'

class Main extends Component {

  render() {
    return (
      <div id="content" className="mt-3">

        <table className="table table-borderless text-muted text-center">
          <thead>
            <tr>
              <th scope="col">Donation Balance</th>
            </tr>
          </thead>
          <tbody>
            <tr>
              <td>{window.web3.utils.fromWei(this.props.donatingBalance, 'Ether')} DAI</td>
            </tr>
          </tbody>
        </table>

        <div className="card mb-4" >

          <div className="card-body">

            <form className="mb-3" onSubmit={(event) => {
                event.preventDefault()
                let amount
                amount = this.input.value.toString()
                amount = window.web3.utils.toWei(amount, 'Ether')
                this.props.donateTokens(amount)
              }}>
              <div>
                <label className="float-left"><b>Donate tokens</b></label>
                <span className="float-right text-muted">
                  Balance: {window.web3.utils.fromWei(this.props.daiTokenBalance, 'Ether')}
                </span>
              </div>
              <div className="input-group mb-4">
                <input
                  type="text"
                  ref={(input) => { this.input = input }}
                  className="form-control form-control-lg"
                  placeholder="0"
                  required />
                <div className="input-group-append">
                  <div className="input-group-text">
                    <img src={dai} height='32' alt=""/>
                    &nbsp;&nbsp;&nbsp; DAI
                  </div>
                </div>
              </div>
              <button type="submit" className="btn btn-primary btn-block btn-lg">Donate</button>
            </form>

          </div>
        </div>

      </div>
    );
  }
}

export default Main;

App

The App.js file loads created smart contracts and imports files for the navigation bar (Navbar.js) and Main.js.

Here is the code:

import React, { Component } from 'react'
import Web3 from 'web3'
import DaiToken from '../abis/DaiToken.json'
import TokenFarm from '../abis/TokenFarm.json'
import Navbar from './Navbar'
import Main from './Main'
import './App.css'

class App extends Component {

  async componentWillMount() {
    await this.loadWeb3()
    await this.loadBlockchainData()
  }

  async loadBlockchainData() {
    const web3 = window.web3

    const accounts = await web3.eth.getAccounts()
    this.setState({ account: accounts[0] })

    const networkId = await web3.eth.net.getId()

    // Load DaiToken
    const daiTokenData = DaiToken.networks[networkId]
    if(daiTokenData) {
      const daiToken = new web3.eth.Contract(DaiToken.abi, daiTokenData.address)
      this.setState({ daiToken })
      let daiTokenBalance = await daiToken.methods.balanceOf(this.state.account).call()
      this.setState({ daiTokenBalance: daiTokenBalance.toString() })
    } else {
      window.alert('DaiToken contract not deployed to detected network.')
    }


    // Load TokenFarm
    const tokenFarmData = TokenFarm.networks[networkId]
    if(tokenFarmData) {
      const tokenFarm = new web3.eth.Contract(TokenFarm.abi, tokenFarmData.address)
      this.setState({ tokenFarm })
      let donatingBalance = await tokenFarm.methods.donatingBalance(this.state.account).call()
      this.setState({ donatingBalance: donatingBalance.toString() })
    } else {
      window.alert('TokenFarm contract not deployed to detected network.')
    }

  }

  async loadWeb3() {
    if (window.ethereum) {
      window.web3 = new Web3(window.ethereum)
      await window.ethereum.enable()
    }
    else if (window.web3) {
      window.web3 = new Web3(window.web3.currentProvider)
    }
    else {
      window.alert('Non-Ethereum browser detected. You should consider trying MetaMask!')
    }
  }

  donateTokens = (amount) => {
	  
        //	sometimes this.state call is not working (if you e.g. didn't build a project), and you should try to 
       // hard-code an 0x addresses here (not recommended)
	 this.state.daiToken.methods.approve(this.state.tokenFarm._address, amount).send({ from: this.state.account }).on('transactionHash', (hash) => {
      this.state.tokenFarm.methods.donateTokens(amount).send({ from: this.state.account }).on('transactionHash', (hash) => {
		  
		        })
    })
  }


  constructor(props) {
    super(props)
    this.state = {
      account: '0x0',
      daiToken: {},
      dappToken: {},
      tokenFarm: {},
      daiTokenBalance: '0',
      donatingBalance: '0'
    }
  }

  render() {
    let content
      content = <Main
        daiTokenBalance={this.state.daiTokenBalance}
        donatingBalance={this.state.donatingBalance}
        donateTokens={this.donateTokens}
      />

    return (
      <div>
        <Navbar account={this.state.account} />
        <div className="container-fluid mt-5">
          <div className="row">
            <main role="main" className="col-lg-12 ml-auto mr-auto" style={{ maxWidth: '600px' }}>
              <div className="content mr-auto ml-auto">
                <a
                  href="<some URL>"
                  target="_blank"
                  rel="noopener noreferrer"
                >
                </a>

                {content}

              </div>
            </main>
          </div>
        </div>
      </div>
    );
  }
}

export default App;

Index.js

The index.js file serves to call selected functions.

Here is the code:

import React from 'react';
import ReactDOM from 'react-dom';
import 'bootstrap/dist/css/bootstrap.css'
import App from './components/App';
import * as serviceWorker from './serviceWorker';

ReactDOM.render(<App />, document.getElementById('root'));

serviceWorker.unregister();

Integration of Ganache with MetaMask

There is one more step to be able to run this project, and that is to integrate Ganache with MetaMask. This enables you to select an account via MetaMask, from Ganache.

The following image shows the main GUI of Ganache:

ganache.JPG

Here, the RPC Server parameter is important. RPC stands for Remote Procedure Call, which is a request-response protocol. We have configured the host (127.0.0.1), and the port (7545) in the truffle-config.js file.

We will create a custom RPC network in MetaMask, based on these parameters:

  • Open MetaMask extension in a browser
  • Go to Settings - Networks - Add a Network
  • Enter the following, from the picture below:

metamask.JPG

  • Press the Save button

Now you have created a custom RPC network.

The next step is to import some accounts from Ganache to MetaMask. For this, you will need a private key of an account:

  • Open Accounts tab in Ganache
  • Choose an account and get its private key, as shown in the picture below (click on the key):

key.JPG

  • Copy the private key
  • Open MetaMask
  • Go to Import Account
  • Paste a private key

Run the project

Now that everything is set, you can run the project:

  • Open Ganache Desktop
  • In the command prompt, run the following command:
    npm start
    
  • Wait until you see that it is successfully compiled and until you see a port for calling
  • Call the project in a browser by entering the following in your URL address:

localhost:<port>

Note that it should automatically open the project in a browser, without an explicit call.

Conclusion

In this article, we talked about Ethereum decentralized applications (DApps), theory and implementation-wise.

In the DApps theory section, we've seen the following:

  • Three architecture types that are adopted by Ethereum DApps
  • DApp categories
  • Types of cost of smart contracts in DApps

In the DApps practical section, we've seen the following:

  • Real problem (task) that was needed to be implemented
  • Technology stack
  • Tools and technologies description
  • Dependency installation
  • A complete file structure of the project
  • Implementation and running of the project
Author's avatar
Nemanja Grubor
My name is Nemanja Grubor. I am a Bachelor of Engineering student experienced in Oracle PL/SQL Database Development and Core PHP. I am currently working as a freelance content writer at WorksHub.

Related Issues

open-editions / corpus-joyce-ulysses-tei
open-editions / corpus-joyce-ulysses-tei
  • Open
  • 0
  • 0
  • Intermediate
  • HTML
open-editions / corpus-joyce-ulysses-tei
open-editions / corpus-joyce-ulysses-tei
  • Started
  • 0
  • 1
  • Intermediate
  • HTML
open-editions / corpus-joyce-ulysses-tei
open-editions / corpus-joyce-ulysses-tei
  • Open
  • 0
  • 0
  • Intermediate
  • HTML
open-editions / corpus-joyce-ulysses-tei
open-editions / corpus-joyce-ulysses-tei
  • Open
  • 0
  • 0
  • Intermediate
  • HTML

Get hired!

Sign up now and apply for roles at companies that interest you.

Engineers who find a new job through WorksHub average a 15% increase in salary.

Start with GitHubStart with Stack OverflowStart with Email