Skip to main content

Universal Counter App

Welcome to the first tutorial of building a truly universal smart contract. The smart contract we are going to build here is the popular Counter app, that all Solidity developers are familiar with. This counter app, however, is going to be Universal.

We will first understand what we are building and why it is a unique (one-of-a-kind) counter app. Let’s dive in 🤿.

What’s Unique About This App?

A typical Solidity counter app allows you to increment a specific variable when a caller (msg.sender) calls it.

However, the counter app we will build now will be a sophisticated version of the simple counter contract.

The UniversalCounter app we are going to build includes:

  1. Different uint256 counter variables for different chains (e.g., countETH, countPC, countSOL, etc).
  2. A check on the caller (msg.sender) who invokes the increment() function.
  3. Native identification of the origin chain of the caller.
  4. Increment logic that updates only the counter specific to the caller’s origin chain.

Example Behavior

  • Bob is an Ethereum user → Bob calls increment()countETH variable is incremented.
  • Dan is a Push Chain user → Dan calls increment()countPC variable is incremented.

🚀 The Best Part: You’ll be able to build this cross-chain functionality without using oracles, message passing systems, or third-party interoperability providers.

It’s all natively supported on Push Chain.

Let's Build

Here is the solidity code for our Universal Counter smart contract.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;

// Universal Account ID Struct and IUEAFactory Interface

struct UniversalAccountId {
string chainNamespace;
string chainId;
bytes owner;
}

interface IUEAFactory {
function getOriginForUEA(address addr) external view returns (UniversalAccountId memory account, bool isUEA);
}

contract UniversalCounter {
uint256 public countEth;
uint256 public countSol;
uint256 public countPC;

event CountIncremented(
uint256 newCount,
address indexed caller,
string chainNamespace,
string chainId
);

constructor() {}

function increment() public {
address caller = msg.sender;
(UniversalAccountId memory originAccount, bool isUEA) =
IUEAFactory(0x00000000000000000000000000000000000000eA).getOriginForUEA(caller);

if (!isUEA) {
// If it's a native Push Chain EOA (isUEA = false)
countPC += 1;
} else {
bytes32 chainHash = keccak256(abi.encodePacked(originAccount.chainNamespace, originAccount.chainId));

if (chainHash == keccak256(abi.encodePacked("solana","EtWTRABZaYq6iMfeYKouRu166VU2xqa1"))) {
countSol += 1;
} else if (chainHash == keccak256(abi.encodePacked("eip155","11155111"))) {
countEth += 1;
} else {
revert("Invalid chain");
}
}

emit CountIncremented(getCount(), caller, originAccount.chainNamespace, originAccount.chainId);
}

function reset() public {
countEth = 0;
countSol = 0;
countPC = 0;
}

function getCount() public view returns (uint256) {
return countEth + countSol + countPC;
}
}

Understanding the Universal Counter App

The unique aspect of this smart contract is its ability to determine all imperative details of the user ( msg.sender ) instantly and natively.

In simpler terms, for any given msg.sender address, the contract is able to quickly identify:

  1. the actual source chain of the caller
  2. the chain id of the source chain of the caller.
  3. the address of the caller on the source chain.

These details are natively available for any smart contract built on Push Chain. This is enabled via UEAFactory Interface.

Using the UEAFactory Interface

The first step is to achieve the universal functionality is to use the UEAFactory interface in our contract. This can either be imported or directly included in your contract.

This interfaces provides you with the function - getOriginForUEA().

    /**
* @dev Returns the owner key (UOA) for a given UEA address
* @param addr Any given address ( msg.sender ) on push chain
* @return account The Universal Account information associated with this UEA
* @return isUEA True if the address addr is a UEA contract. Else it is a native EOA of PUSH chain (i.e., isUEA = false)
*/
function getOriginForUEA(address addr) external view returns (UniversalAccountId memory account, bool isUEA);

This function plays the critical role of fetching and returning the information about the caller ( msg.sender ).

The function mainly returns 2 crucial values:

  • The UniversalAccountId of the user, and
  • A boolean that indicates whether or not this caller is a UEA.

Designing the Increment Function

The increment function is the main logic of this contract that updates the count variables based on user’s origin type.

In order to achieve this, the increment function does the following:

  • calls the getOriginForUEA() with msg.sender as argument
  • this provides us with isUEA and UniversalAccountId for the caller.
  • then we check if isUEA is false, this means the caller is a native Push User.
  • for such users, the function increments countPC variable by 1
Why isUEA = false means native Push User?

1. Every external chain user (ETH, Solana, etc) in Push Chain has a UEA account deployed for them.

2. These UEA accounts represent the external chain users on Push Chain and are directly controlled by their signatures.

3. UEAs allow external users to interact and use Push Chain apps without natively being on Push Chain.

4. Therefore, for a given msg.sender:

  • isUEA = false → the caller is a native Push Chain account and not an external chain user.
  • isUEA = true → the caller is an external chain user interacting via a UEA. For such a user, the UniversalAccountId shall provide all information like { chainName, chainId, ownerAddress }.
  • however, if isUEA is true, this indicates the user is a external chain user.
  • for such users the function checks the UniversalAccountId.chainNamespace and UniversalAccountId.chainId of the user and identifies if the user is a Solana or Ethereum user and updates the countSol or countEth variable accordingly.

Summary

With this, we have simply achieved:

  • a contract that natively identifies the caller of ANY chain.
  • allows devs to build logic specific to the users for a particular chain.
  • simplifies developer experience for building multi-chain universal apps.
  • eliminates any use of third-party tooling, oracles, to achieve universal behavior.

This makes our Counter smart contract truly universal with just a few lines of solidity codes.

Interact with UniversalCounter App

A easier way to interact with the contract is to use the LivePlayground below. The UniversalCounter app is already deployed on Push Chain Testnet.

UniversalCounter Contract Address: 0x5A59a5Ac94d5190553821307F98e4673BF3c4a1D

Note: Push Chain easily allows you to interact with the UniversalCounter from any chain.

Follow the steps below to interact with the UniversalCounter:

  • Connect your wallet to the LivePlayground.
  • You can connect wallet of any supported chain ( Push Chain, Ethereum or Solana)
  • Click on the Increment Counter button to increment the counter.
  • Click on the Refresh Counter Values button to refresh the counter values.
  • Click on the View in Explorer button to view the transaction in the explorer.

Let's Test our UniversalCounter App

REACT PLAYGROUND
import React, { useState, useEffect } from 'react';
import { ethers } from 'ethers';
import {
  PushUniversalWalletProvider,
  PushUniversalAccountButton,
  usePushWalletContext,
  usePushChainClient,
  PushUI,
} from '@pushchain/ui-kit';

function UniversalCounterExample() {
  // Define Wallet Config
  const walletConfig = {
    network: PushUI.CONSTANTS.PUSH_NETWORK.TESTNET,
  };

  // Define Universal Counter ABI, taking minimal ABI for the demo
  const UCABI = [
    {
      inputs: [],
      name: 'increment',
      outputs: [],
      stateMutability: 'nonpayable',
      type: 'function',
    },
    {
      inputs: [],
      name: 'countEth',
      outputs: [
        {
          internalType: 'uint256',
          name: '',
          type: 'uint256',
        },
      ],
      stateMutability: 'view',
      type: 'function',
    },
    {
      inputs: [],
      name: 'countPC',
      outputs: [
        {
          internalType: 'uint256',
          name: '',
          type: 'uint256',
        },
      ],
      stateMutability: 'view',
      type: 'function',
    },
    {
      inputs: [],
      name: 'countSol',
      outputs: [
        {
          internalType: 'uint256',
          name: '',
          type: 'uint256',
        },
      ],
      stateMutability: 'view',
      type: 'function',
    },
  ];

  // Contract address for the Universal Counter
  const CONTRACT_ADDRESS = '0x5A59a5Ac94d5190553821307F98e4673BF3c4a1D';

  function Component() {
    const { connectionStatus } = usePushWalletContext();
    const { pushChainClient } = usePushChainClient();

    // State to store counter values
    const [countEth, setCountEth] = useState(-1);
    const [countSol, setCountSol] = useState(-1);
    const [countPC, setCountPC] = useState(-1);
    const [isLoading, setIsLoading] = useState(false);
    const [txHash, setTxHash] = useState('');

    // Function to encode transaction data
    const getTxData = () => {
      return PushChain.utils.helpers.encodeTxData({
        abi: UCABI,
        functionName: 'increment',
      });
    };

    // Function to fetch counter values
    const fetchCounters = async () => {
      if (!pushChainClient) return;

      try {
        // Create a contract instance for read operations
        const provider = new ethers.JsonRpcProvider(
          'https://evm.rpc-testnet-donut-node1.push.org/'
        );
        const contract = new ethers.Contract(CONTRACT_ADDRESS, UCABI, provider);

        // Fetch counter values
        const ethCount = await contract.countEth();
        const solCount = await contract.countSol();
        const pcCount = await contract.countPC();

        // Update state
        setCountEth(Number(ethCount));
        setCountSol(Number(solCount));
        setCountPC(Number(pcCount));
      } catch (err) {
        console.error('Error fetching counter values:', err);
      }
    };

    // Fetch counter values on component mount and when connection status changes
    useEffect(() => {
      if (connectionStatus === PushUI.CONSTANTS.CONNECTION.STATUS.CONNECTED) {
        fetchCounters();
      }
    }, [connectionStatus, pushChainClient]);

    // Handle transaction to increment counter
    const handleSendTransaction = async () => {
      if (pushChainClient) {
        try {
          setIsLoading(true);
          const data = getTxData();

          const tx = await pushChainClient.universal.sendTransaction({
            to: CONTRACT_ADDRESS,
            value: BigInt(0),
            data: data,
          });

          setTxHash(tx.hash);

          // Wait for transaction to be mined
          await tx.wait();

          // Refresh counter values
          await fetchCounters();
          setIsLoading(false);
        } catch (err) {
          console.error('Transaction error:', err);
          setIsLoading(false);
        }
      }
    };

    // Function to determine which chain is winning
    const getWinningChain = () => {
      if (countEth === -1 || countSol === -1 || countPC === -1) return null;

      if (countEth > countSol && countEth > countPC) {
        return `Ethereum is winning with ${countEth} counts`;
      } else if (countSol > countEth && countSol > countPC) {
        return `Solana is winning with ${countSol} counts`;
      } else if (countPC > countEth && countPC > countSol) {
        return `Push Chain is winning with ${countPC} counts`;
      } else {
        // Handle ties
        if (countEth === countSol && countEth === countPC && countEth > 0) {
          return `It's a three-way tie with ${countEth} counts each`;
        } else if (countEth === countSol && countEth > countPC) {
          return `Ethereum and Solana are tied with ${countEth} counts each`;
        } else if (countEth === countPC && countEth > countSol) {
          return `Ethereum and Push Chain are tied with ${countEth} counts each`;
        } else if (countSol === countPC && countSol > countEth) {
          return `Solana and Push Chain are tied with ${countSol} counts each`;
        } else {
          return null; // No winner yet or all zeros
        }
      }
    };

    const winningMessage = getWinningChain();

    return (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          gap: '12px',
        }}
      >
        <h2>Universal Counter Example</h2>

        <PushUniversalAccountButton />

        {connectionStatus !== PushUI.CONSTANTS.CONNECTION.STATUS.CONNECTED && (
          <p>Please connect your wallet to interact with the counter.</p>
        )}

        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            gap: '12px',
            width: '100%',
            flexWrap: 'nowrap',
          }}
        >
          <h3>
            Total Universal Count:{' '}
            {countEth == -1 ? '...' : countEth + countSol + countPC}
          </h3>

          <div
            style={{
              display: 'flex',
              flexDirection: 'row',
              alignItems: 'center',
              justifyContent: 'space-around',
              gap: '12px',
              width: '100%',
            }}
          >
            <div className='counter-box'>
              <h3>ETH Counter: {countEth == -1 ? '...' : countEth}</h3>
            </div>

            <div className='counter-box'>
              <h3>Sol Counter: {countSol == -1 ? '...' : countSol}</h3>
            </div>

            <div className='counter-box'>
              <h3>PC Counter: {countPC == -1 ? '...' : countPC}</h3>
            </div>
          </div>
        </div>

        {connectionStatus === PushUI.CONSTANTS.CONNECTION.STATUS.CONNECTED && (
          <div className='counter-container' style={{ display: 'flex', flexDirection: 'column', gap: '16px', alignItems: 'center' }}>
            <div style={{ display: 'flex', justifyContent: 'center', gap: '20px' }}>
              <button
                className='increment-button'
                onClick={handleSendTransaction}
                disabled={isLoading}
                style={{
                  backgroundColor: '#d946ef',
                  color: 'white',
                  border: 'none',
                  borderRadius: '20px',
                  padding: '8px 16px',
                  fontSize: '14px',
                  cursor: 'pointer',
                  fontWeight: 'bold'
                }}
              >
                {isLoading ? 'Processing...' : 'Increment Counter'}
              </button>

              <button
                className='refresh-button'
                onClick={fetchCounters}
                style={{
                  backgroundColor: '#d946ef',
                  color: 'white',
                  border: 'none',
                  borderRadius: '20px',
                  padding: '8px 16px',
                  fontSize: '14px',
                  cursor: 'pointer',
                  fontWeight: 'bold'
                }}
              >
                Refresh Counter Values
              </button>
            </div>

            {winningMessage && (
              <div style={{ margin: '10px 0', fontWeight: 'bold', color: '#d946ef' }}>
                {winningMessage}
              </div>
            )}

            {txHash && pushChainClient && (
              <div className='transaction-info' style={{ textAlign: 'center' }}>
                <p>
                  Transaction Hash:{' '}
                  <a
                    href={pushChainClient.explorer.getTransactionUrl(txHash)}
                    target='_blank'
                    style={{ color: '#d946ef', textDecoration: 'underline' }}
                  >
                    {txHash}
                  </a>
                </p>
              </div>
            )}
          </div>
        )}
      </div>
    );
  }

  return (
    <PushUniversalWalletProvider config={walletConfig}>
      <Component />
    </PushUniversalWalletProvider>
  );
}
LIVE APP PREVIEW