Accepting Crypto Payments in a Classic Commerce App

Accepting Crypto Payments in a Classic Commerce App

E-commerce storefronts have been slow to offer crypto payment methods to their customers. Crypto payment plug-ins or payment gateway integrations aren't generally available, or they rely on third-party custodians to collect, exchange, and distribute money. Considering the growing ownership rate and experimentation ratio of cryptocurrencies, a "pay with crypto" button could greatly drive sales.

This article demonstrates how you can integrate a custom, secure crypto payment method into any online store without relying on a third-party service. Coding and maintaining smart contracts needs quite some heavy lifting under the hood, a job that we’re handing over to Truffle suite, a commonly used toolchain for blockchain builders. To provide access to blockchain nodes during development and for the application backend we rely on Infura nodes that offer access to the Ethereum network at a generous free tier. Using these tools together will make the development process much easier.

Scenario: The Amethon Bookstore

The goal is to build a storefront for downloadable eBooks that accepts the Ethereum blockchain's native currency ("Ether") and ERC20 stablecoins (payment tokens pegged in USD) as a payment method. Let’s refer to it as "Amethon" from here on. A full implementation can be found on the accompanying github monorepo. All code is written in Typescript and can be compiled using the package's yarn build or yarn dev commands.

We’ll walk you through the process step by step, but familiarity with smart contracts, Ethereum, and minimal knowledge of the Solidity programming language might be helpful to read along. We recommend you to read some fundamentals first to become familiar with the ecosystem’s basic concepts.

Application Structure

The store backend is built as a CRUD API that is not connected to any blockchain itself. Its frontend triggers payment requests on that API, which customers fulfill using their crypto wallets.

Amethon is designed as a "traditional" ecommerce application that takes care of the business logic and doesn't rely on any on-chain data besides the payment itself. During checkout, the backend issues PaymentRequest objects that carry a unique identifier (such as an "invoice number") that users attach to their payment transactions.

A background daemon listens to the respective contract events and updates the store's database when it detects a payment.

Payment settlements on Amethon

The PaymentReceiver Contract

At the center of Amethon, the PaymentReceiver smart contract accepts and escrows payments on behalf of the storefront owner.

Each time a user sends funds to the PaymentReceiver contract, a PaymentReceived event is emitted containing information about the payment's origin (the customer's Ethereum account), its total value, the ERC20 token contract address utilized, and the paymentId that refers to the backend's database entry.

 event PaymentReceived(
    address indexed buyer,
    uint256 value,
    address token,
    bytes32 paymentId
  );

Ethereum contracts act similarly to user-based (aka "externally owned" / EOA) accounts and get their own account address upon deployment. Receiving the native Ether currency requires implementing thereceiveandfallback functions which are invoked when someone transfers Ether funds to the contract, and no other function signature matches the call:

 receive() external payable {
    emit PaymentReceived(msg.sender, msg.value, ETH_ADDRESS, bytes32(0));
  }

  fallback() external payable {
    emit PaymentReceived(
      msg.sender, msg.value, ETH_ADDRESS, bytes32(msg.data));
  }

The official Solidity docs point out the subtle difference between these functions: receive is invoked when the incoming transaction doesn't contain additional data, otherwise fallback is called. The native currency of Ethereum itself is not an ERC20 token and has no utility besides being a counting unit. However, it has an identifiable address (0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE) that we use to signal an Ether payment in our PaymentReceived events.

Ether transfers, however, have a major shortcoming: the amount of allowed computation upon reception is extremely low. The gas sent along by customers merely allows us to emit an event but not to redirect funds to the store owner's original address. Therefore, the receiver contract keeps all incoming Ethers and allows the store owner to release them to their own account at any time:

function getBalance() public view returns (uint256) {
  return address(this).balance;
}

function release() external onlyOwner {
  (bool ok, ) = _owner.call{value: getBalance()}("");
  require(ok, "Failed to release Eth");
}

Accepting ERC20 tokens as a payment is slightly more difficult for historical reasons. In 2015, the authors of the initial specification couldn't predict the upcoming requirements and kept the ERC20 standard's interface as simple as possible. Most notably, ERC20 contracts aren't guaranteed to notify recipients about transfers, so there's no way for our PaymentReceiver to execute code when ERC20 tokens are transferred to it.

The ERC20 ecosystem has evolved and now includes additional specs. For example, the EIP 1363 standard addresses this very problem. Unfortunately, you cannot rely on major stablecoin platforms to have implemented it.

So Amethon must accept ERC20 token payments in the "classic" way. Instead of "dropping" tokens on it unwittingly, the contract takes care of the transfer on behalf of the customer. This requires users to first allow the contract to handle a certain amount of their funds. This inconveniently requires users to first transmit an Approval transaction to the ERC20 token contract before interacting with the real payment method. EIP-2612 might improve this situation, however, we have to play by the old rules for the time being.

 function payWithErc20(
    IERC20 erc20,
    uint256 amount,
    uint256 paymentId
  ) external {
    erc20.transferFrom(msg.sender, _owner, amount);
    emit PaymentReceived(
      msg.sender,
      amount,
      address(erc20),
      bytes32(paymentId)
    );
  }

Compiling, Deploying, and Variable Safety

Several toolchains allow developers to compile, deploy, and interact with Ethereum smart contracts, but one of the most advanced ones is the Truffle Suite. It comes with a built-in development blockchain based on Ganache, and a migration concept that allows you to automate and safely run contract deployments.

Deploying contracts on "real" blockchain infrastructure, such as Ethereum testnets, requires two things: an Ethereum provider that's connected to a blockchain node and either the private keys / wallet mnemonics of an account or a wallet connection that can sign transactions on behalf of an account. The account also needs to have some (testnet) Ethers on it to pay for gas fees during deployment.

MetaMask does that job. Create a new account that you're not using for anything else but deployment (it will become the "owner" of the contract) and fund it with some Ethers using your preferred testnet's faucet (we recommend Paradigm). Usually you would now export that account's private key ("Account Details" > "Export Private Key") and wire it up with your development environment but to circumvent all security issues implied by that workflow, Truffle comes with a dedicated dashboard network and web application that can be used to sign transactions like contract deployments using Metamask inside a browser. To start it up, execute truffle dashboard in a fresh terminal window and visit http://localhost:24012/ using a browser with an active Metamask extension.

Using truffle’s dashboard to sign transactions without exposing private keys

The Amethon project also relies on various secret settings. Note that due to the way dotenv-flow works, .env files contain samples or publicly visible settings, which are overridden by gitignored .env.local files. Copy all .env files in the packages' subdirectories to .env.locals and override their values.

To connect your local environment to an Ethereum network, access a synced blockchain node. While you certainly could download one of the many clients and wait for it to sync on your machine, it is far more convenient to connect your applications to Ethereum nodes that are offered as a service, the most well-known being Infura. Their free tier provides you with three different access keys and 100k RPC requests per month supporting a wide range of Ethereum networks.

After signup, take note of your Infura key and put it in your contracts .env.local as INFURA_KEY.

If you'd like to interact with contracts, e.g. on the Kovan network, simply add the respective truffle configuration and an --network kovan option to all your truffle commands. You can even start an interactive console: yarn truffle console --network kovan. There isn’t any special setup process needed to test contracts locally. To make our lives simple we’re using the providers and signers injected by Metamask through the truffle dashboard provider instead.

Change to the contracts folder and run yarn truffle develop. This will start a local blockchain with prefunded accounts and open a connected console on it. To connect your Metamask wallet to the development network, create a new network using http://localhost:9545 as its RPC endpoint. Take note of the accounts listed when the chain starts: you can import their private keys into your Metamask wallet to send transactions on their behalf on your local blockchain.

Type compile to compile all contracts at once and deploy them to the local chain with migrate. You can interact with contracts by requesting their currently deployed instance and call its functions like so:

pr = await PaymentReceiver.deployed()
balance = await pr.getBalance()

Once you're satisfied with your results, you can then deploy them on a public testnet (or mainnet), as well:

yarn truffle migrate --interactive --network dashboard

The Backend

The Store API / CRUD

Our backend provides a JSON API to interact with payment entities on a high level. We've decided to use TypeORM and a local SQLite database to support entities for Books and PaymentRequests. Books represent our shop's main entity and have a retail price, denoted in USD cents. To initially seed the database with books, you can use the accompanying seed.ts file. After compiling the file, you can execute it by invoking node build/seed.js.

//backend/src/entities/Book.ts
import { Entity, Column, PrimaryColumn, OneToMany } from "typeorm";
import { PaymentRequest } from "./PaymentRequest";

@Entity()
export class Book {
  @PrimaryColumn()
  ISBN: string;

  @Column()
  title: string;

  @Column()
  retailUSDCent: number;

  @OneToMany(
    () => PaymentRequest,
    (paymentRequest: PaymentRequest) => paymentRequest.book
  )
  payments: PaymentRequest[];
}

Heads up: storing monetary values as float values is strongly discouraged on any computer system because operating on float values will certainly introduce precision errors. This is also why all crypto tokens operate with 18 decimal digits and Solidity doesn't even have a float data type. 1 Ether actually represents "1000000000000000000" wei, the smallest Ether unit.

For users who intend to buy a book from Amethon, create an individual PaymentRequest for their item first by calling the /books/:isbn/order route. This creates a new unique identifier that must be sent along with each request.

We're using plain integers here, however, for real-world use cases you'll use something more sophisticated. The only restriction is the id's binary length that must fit into 32 bytes (uint256). Each PaymentRequest inherits the book's retail value in USD cents and bears the customer's address, fulfilledHash and paidUSDCent will be determined during the buying process.

//backend/src/entities/PaymentRequest.ts
@Entity()
export class PaymentRequest {
  @PrimaryGeneratedColumn()
  id: number;

  @Column("varchar", { nullable: true })
  fulfilledHash: string | null;

  @Column()
  address: string;

  @Column()
  priceInUSDCent: number;

  @Column("mediumint", { nullable: true })
  paidUSDCent: number;

  @ManyToOne(() => Book, (book) => book.payments)
  book: Book;
}

An initial order request that creates a PaymentRequest entity looks like this:

POST http://localhost:3001/books/978-0060850524/order
Content-Type: application/json

{
  "address": "0xceeca1AFA5FfF2Fe43ebE1F5b82ca9Deb6DE3E42"
}
--->
{
  "paymentRequest": {
    "book": {
      "ISBN": "978-0060850524",
      "title": "Brave New World",
      "retailUSDCent": 1034
    },
    "address": "0xceeca1AFA5FfF2Fe43ebE1F5b82ca9Deb6DE3E42",
    "priceInUSDCent": 1034,
    "fulfilledHash": null,
    "paidUSDCent": null,
    "id": 6
  },
  "receiver": "0x7A08b6002bec4B52907B4Ac26f321Dfe279B63E9"
}

The Blockchain Listener Background Service

Querying a blockchain's state tree doesn't cost clients any gas but nodes still need to compute. When those operations become too computation-heavy, they can time out. For real-time interactions, it is highly recommended to not poll chain state but rather listen to events emitted by transactions. This requires the use of WebSocket enabled providers, so make sure to use the Infura endpoints that start with wss:// as URL scheme for your backend's PROVIDER_RPC environment variable. Then you can start the backend's daemon.ts script and listen for PaymentReceived events on any chain:

//backend/src/daemon.ts
  const web3 = new Web3(process.env.PROVIDER_RPC as string);
  const paymentReceiver = new web3.eth.Contract(
    paymentReceiverAbi as AbiItem[],
    process.env.PAYMENT_RECEIVER_CONTRACT as string
  );

  const emitter = paymentReceiver.events.PaymentReceived({
    fromBlock: "0",
  });

  emitter.on("data", handlePaymentEvent);
})();

Take note of how we're instantiating the Contract instance with an Application Binary Interface. The Solidity compiler generates the ABI and contains information for RPC clients on how to encode transactions to invoke and decode functions, events or parameters on a smart contract.

Once instantiated, you can hook a listener on the contract's PaymentReceived logs (starting at block 0) and handle them once received.

Since Amethon supports Ether and stablecoin ("USD") payments, the daemon's handlePaymentEvent method first checks which token has been used in the user's payment and computes its dollar value, if needed:

//backend/src/daemon.ts
const ETH_USD_CENT = 2_200 * 100;
const ACCEPTED_USD_TOKENS = (process.env.STABLECOINS as string).split(",");
const NATIVE_ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";

const handlePaymentEvent = async (event: PaymentReceivedEvent) => {
  const args = event.returnValues;
  const paymentId = web3.utils.hexToNumber(args.paymentId);
  const decimalValue = web3.utils.fromWei(args.value);
  const payment = await paymentRepo.findOne({ where: { id: paymentId } });
  let valInUSDCents;
  if (args.token === NATIVE_ETH) {
    valInUSDCents = parseFloat(decimalValue) * ETH_USD_CENT;
  } else {
    if (!ACCEPTED_USD_TOKENS.includes(args.token)) {
      return console.error("payments of that token are not supported");
    }
    valInUSDCents = parseFloat(decimalValue) * 100;
  }

  if (valInUSDCents < payment.priceInUSDCent) {
    return console.error(`payment [${paymentId}] not sufficient`);
  }

  payment.paidUSDCent = valInUSDCents;
  payment.fulfilledHash = event.transactionHash;
  await paymentRepo.save(payment);
};

The Frontend

Our bookstore's frontend is built on the official Create React App template with Typescript support and uses Tailwind for basic styles. It supports all known CRA scripts so you can start it locally by yarn start after you created your own .env.local file containing the payment receiver and stablecoin contract addresses you created before.

Heads up: CRA5 bumped their webpack dependency to a version that no longer supports node polyfills in browsers. This breaks the builds of nearly all Ethereum-related projects today. A common workaround that avoids ejecting is to hook into the CRA build process. We’re using react-app-rewired but you could simply stay at CRA4 until the community comes up with a better solution.

Connecting a Web3 Wallet

The crucial part of any Dapp is connecting to a user's wallet. You could try to manually wire that process following the official MetaMask docs but we strongly recommend using an appropriate React library. We found Noah Zinsmeister's web3-react to be the best. Detecting and connecting a web3 client boils down to this code (ConnectButton.tsx):

//frontend/src/components/ConnectButton.ts
import { useWeb3React } from "@web3-react/core";
import { InjectedConnector } from "@web3-react/injected-connector";
import React from "react";
import Web3 from "web3";

export const injectedConnector = new InjectedConnector({
  supportedChainIds: [42, 1337, 31337], //Kovan, Truffle, Hardhat
});

export const ConnectButton = () => {
  const { activate, account, active } = useWeb3React<Web3>();

  const connect = () => {
    activate(injectedConnector, console.error);
  };

  return active ? (
    <div className="text-sm">connected as: {account}</div>
  ) : (
    <button className="btn-primary" onClick={connect}>
      Connect
    </button>
  );
};

By wrapping your App's code in an <Web3ReactProvider getLibrary={getWeb3Library}> context you can access the web3 provider, account, and connected state using theuseWeb3React hook from any component. Since Web3React is agnostic to the web3 library being used (Web3.js or ethers.js), you must provide a callback that yields a connected "library":

//frontend/src/App.tsx
import Web3 from "web3";
function getWeb3Library(provider: any) {
  return new Web3(provider);
}

Payment Flows

After loading the available books from the Amethon backend, the <BookView> component first checks whether payments for this user have already been processed and then displays all supported payment options bundled inside the <PaymentOptions> component.

Paying With ETH

The <PayButton> is responsible for initiating direct Ether transfers to the PaymentReceiver contract. Since these calls are not interacting with the contract's interface directly, we don't even need to initialize a contract instance:

//frontend/src/components/PayButton.tsx
const weiPrice = usdInEth(paymentRequest.priceInUSDCent);

const tx = web3.eth.sendTransaction({
  from: account, //the current user
  to: paymentRequest.receiver.options.address, //the PaymentReceiver contract address
  value: weiPrice, //the eth price in wei (10**18)
  data: paymentRequest.idUint256, //the paymentRequest's id, converted to a uint256 hex string
});
const receipt = await tx;
onConfirmed(receipt);

As explained earlier, since the new transaction carries a msg.data field, Solidity's convention triggers the PaymentReceiver's fallback() external payable function that emits a PaymentReceived event with Ether's token address. This is picked up by the daemonized chain listener that updates the backend's database state accordingly.

A static helper function is responsible for converting the current dollar price to an Ether value. In a real-world scenario, query the exchange rates from a trustworthy third party like Coingecko or from a DEX like Uniswap. Doing so allows you to extend Amethon to accept arbitrary tokens as payments.

//frontend/src/modules/index.ts
const ETH_USD_CENT = 2_200 * 100;
export const usdInEth = (usdCent: number) => {
  const eth = (usdCent / ETH_USD_CENT).toString();
  const wei = Web3.utils.toWei(eth, "ether");
  return wei;
};

Paying With ERC20 Stablecoins

For reasons mentioned earlier, payments in ERC20 tokens are slightly more complex from a user's perspective since one cannot simply drop tokens on a contract. Like nearly anyone with a comparable use case, we must first ask the user to give their permission for our PaymentReceivercontract to transfer their funds and call the actual payWithEerc20 method that transfers the requested funds on behalf of the user.

Here's the PayWithStableButton's code for giving the permission on a selected ERC20 token:

//frontend/src/components/PayWithStableButton.tsx
const contract = new web3.eth.Contract(
  IERC20ABI as AbiItem[],
  process.env.REACT_APP_STABLECOINS
);

const appr = await coin.methods
  .approve(
    paymentRequest.receiver.options.address, //receiver contract's address
    price // USD value in wei precision (1$ = 10^18wei)
  )
  .send({
    from: account,
  });

Note that the ABI needed to set up a Contract instance of the ERC20 token receives a general IERC20 ABI. We're using the generated ABI from OpenZeppelin's official library but any other generated ABI would do the job. After approving the transfer we can initiate the payment:

//frontend/src/components/PayWithStableButton.tsx
const contract = new web3.eth.Contract(
  PaymentReceiverAbi as AbiItem[],
  paymentRequest.receiver.options.address
);
const tx = await contract.methods
  .payWithErc20(
    process.env.REACT_APP_STABLECOINS, //identifies the ERC20 contract
    weiPrice, //price in USD (it's a stablecoin)
    paymentRequest.idUint256 //the paymentRequest's id as uint256
  )
  .send({
    from: account,
  });

Signing Download Requests

Finally, our customer can download their eBook. But there's an issue: Since we don’t have a "logged in" user, how do we ensure that only users who actually paid for content can invoke our download route? The answer is a cryptographic signature. Before redirecting users to our backend, the <DownloadButton> component allows users to sign a unique message that is submitted as a proof of account control:

//frontend/src/components/DownloadButton.tsx
const download = async () => {
  const url = `${process.env.REACT_APP_BOOK_SERVER}/books/${book.ISBN}/download`;

  const nonce = Web3.utils.randomHex(32);
  const dataToSign = Web3.utils.keccak256(`${account}${book.ISBN}${nonce}`);

  const signature = await web3.eth.personal.sign(dataToSign, account, "");

  const resp = await (
    await axios.post(
      url,
      {
        address: account,
        nonce,
        signature,
      },
      { responseType: "arraybuffer" }
    )
  ).data;
  // present that buffer as download to the user...
};

The backend's download route can recover the signer's address by assembling the message in the same way the user did before and calling the crypto suite's ecrecover method using the message and the provided signature. If the recovered address matches a fulfilled PaymentRequest on our database, we know that we can permit access to the requested eBook resource:

//backend/src/server.ts
app.post(
  "/books/:isbn/download",
  async (req: DownloadBookRequest, res: Response) => {
    const { signature, address, nonce } = req.body;

    //rebuild the message the user created on their frontend
    const signedMessage = Web3.utils.keccak256(
      `${address}${req.params.isbn}${nonce}`
    );

    //recover the signer's account from message & signature
    const signingAccount = await web3.eth.accounts.recover(
      signedMessage,
      signature,
      false
    );

    if (signingAccount !== address) {
      return res.status(401).json({ error: "not signed by address" });
    }

    //deliver the binary content...
  }
);

The proof of account ownership presented here is still not infallible. Anyone who knows a valid signature for a purchased item can successfully call the download route. The final fix would be to create the random message on the backend first and have the customer sign and approve it. Since users cannot make any sense of the garbled hex code they're supposed to sign, they won’t know if we're going to trick them into signing another valid transaction that might compromise their accounts.

Although we've avoided this attack vector by making use of web3's eth.personal.signmethod it is better to display the message to be signed in a human-friendly way. That's what EIP-712 achieves—a standard already supported by MetaMask.

Conclusion and Next Steps

Accepting payments on ecommerce websites has never been an easy task for developers. While the web3 ecosystem allows storefronts to accept digital currencies, the availability of service-independent plugin solutions falls short. This article demonstrated a safe, simple, and custom way to request and receive crypto payments.

There's room to take the approach a step or two further. Gas costs for ERC20 transfers on the Ethereum mainnet are exceeding our book prices by far. Crypto payments for low-priced items would make sense on gas-friendly environments like Gnosis Chain (their "native" Ether currency is DAI, so you wouldn't even have to worry about stablecoin transfers here) or Arbitrum. You could also extend the backend with cart checkouts or use DEXes to swap any incoming ERC20 tokens into your preferred currency.

After all, the promise of web3 is to allow direct monetary transactions without middlemen and to add great value to online stores that want to engage their crypto-savvy customers.