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:
- Different
uint256
counter variables for different chains (e.g.,countETH
,countPC
,countSOL
, etc). - A check on the caller (msg.sender) who invokes the
increment()
function. - Native identification of the origin chain of the caller.
- 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:
- the actual source chain of the caller
- the chain id of the source chain of the caller.
- 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
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> ); }