E-commerce storefronts have been slow to offer crypto payment methods to their customers. Crypto payment plugins 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 . All code is written in Typescript and can be compiled using the package's or commands. github monorepo yarn build yarn dev 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 objects that carry a unique identifier (such as an "invoice number") that users attach to their payment transactions. PaymentRequest 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 smart contract accepts and escrows payments on behalf of the storefront owner. PaymentReceiver Each time a user sends funds to the contract, a 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 that refers to the backend's database entry. PaymentReceiver PaymentReceived paymentId 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 the and functions which are invoked when someone transfers Ether funds to the contract, and no other function signature matches the call: receive fallback 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 point out the subtle difference between these functions: is invoked when the incoming transaction doesn't contain additional data, otherwise 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 that we use to signal an Ether payment in our events. official Solidity docs receive fallback (0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE) PaymentReceived 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 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 to execute code when ERC20 tokens are transferred to it. initial specification PaymentReceiver The ERC20 ecosystem has evolved and now includes additional specs. For example, the standard addresses this very problem. Unfortunately, you cannot rely on major stablecoin platforms to have implemented it. EIP 1363 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 the contract to handle a certain amount of their funds. This inconveniently requires users to first transmit an transaction to the ERC20 token contract before interacting with the real payment method. might improve this situation, however, we have to play by the old rules for the time being. allow Approval EIP-2612 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 . It comes with a built-in development blockchain based on , and a migration concept that allows you to automate and safely run contract deployments. Truffle Suite Ganache 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 ). 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 in a fresh terminal window and visit using a browser with an active Metamask extension. Paradigm truffle dashboard http://localhost:24012/ The Amethon project also relies on various secret settings. Note that due to the way works, files contain samples or publicly visible settings, which are overridden by gitignored files. Copy all files in the packages' subdirectories to s and override their values. dotenv-flow .env .env.local .env .env.local 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 . Their free tier provides you with three different access keys and 100k RPC requests per month supporting a wide range of Ethereum networks. Infura After signup, take note of your Infura key and put it in your as . contracts .env.local INFURA_KEY If you'd like to interact with contracts, e.g. on the Kovan network, simply add the respective truffle configuration and an option to all your commands. You can even start an interactive console: 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. --network kovan truffle yarn truffle console --network kovan. Change to the folder and run 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 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. contracts yarn truffle develop. http://localhost:9545 Type to compile all contracts at once and deploy them to the local chain with You can interact with contracts by requesting their currently deployed instance and call its functions like so: compile migrate. 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 and a local SQLite database to support entities for and . 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 file. After compiling the file, you can execute it by invoking TypeORM Books PaymentRequests seed.ts 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 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" , the smallest Ether unit. is strongly discouraged wei For users who intend to a book from Amethon, create an individual for their item first by calling the route. This creates a new unique identifier that must be sent along with each request. buy PaymentRequest /books/:isbn/order 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 ( ). Each inherits the book's retail value in USD cents and bears the customer's address, and will be determined during the buying process. uint256 PaymentRequest fulfilledHash paidUSDCent //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 entity looks like this: PaymentRequest 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 as URL scheme for your backend's environment variable. Then you can start the backend's script and listen for events on any chain: wss:// PROVIDER_RPC daemon.ts PaymentReceived //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 instance with an . 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. Contract Application Binary Interface Once instantiated, you can hook a listener on the contract's logs (starting at block 0) and handle them once received. PaymentReceived Since Amethon supports Ether and stablecoin ("USD") payments, the daemon's method first checks which token has been used in the user's payment and computes its dollar value, if needed: handlePaymentEvent //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 template with Typescript support and uses for basic styles. It supports all known CRA scripts so you can start it locally by after you created your own file containing the payment receiver and stablecoin contract addresses you created before. Create React App Tailwind yarn start .env.local 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 but you could simply stay at CRA4 until the community comes up with a better solution. react-app-rewired 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 but we strongly recommend using an appropriate React library. We found Noah Zinsmeister's to be the best. Detecting and connecting a web3 client boils down to this code official MetaMask docs web3-react (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 's code in an context you can access the web3 provider, account, and connected state using the hook from any component. Since Web3React is agnostic to the web3 library being used ( or ), you must provide a callback that yields a connected "library": App <Web3ReactProvider getLibrary={getWeb3Library}> useWeb3React Web3.js ethers.js //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 component first checks whether payments for this user have already been processed and then displays all supported payment options bundled inside the component. <BookView> <PaymentOptions> Paying With ETH The is responsible for initiating direct Ether transfers to the contract. Since these calls are not interacting with the contract's interface directly, we don't even need to initialize a contract instance: <PayButton> PaymentReceiver //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 field, Solidity's convention triggers the function that emits a event with Ether's token address. This is picked up by the daemonized chain listener that updates the backend's database state accordingly. msg.data PaymentReceiver's fallback() external payable PaymentReceived 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 or from a DEX like . Doing so allows you to extend Amethon to accept arbitrary tokens as payments. Coingecko Uniswap //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 for our contract to transfer their funds and call the actual method that transfers the requested funds on behalf of the user. permission PaymentReceiver payWithEerc20 Here's the 's code for giving the permission on a selected ERC20 token: PayWithStableButton //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 instance of the ERC20 token receives a general IERC20 ABI. We're using the generated ABI from but any other generated ABI would do the job. After approving the transfer we can initiate the payment: Contract OpenZeppelin's official library //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 component allows users to sign a unique message that is submitted as a proof of account control: <DownloadButton> //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 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 method using the message and the provided signature. If the recovered address matches a fulfilled on our database, we know that we can permit access to the requested eBook resource: download ecrecover PaymentRequest //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 method it is better to display the message to be signed in a human-friendly way. That's what achieves—a standard . eth.personal.sign EIP-712 already supported by MetaMask Conclusion and Next Steps Accepting payments on e-commerce 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 (their "native" Ether currency is DAI, so you wouldn't even have to worry about stablecoin transfers here) or . You could also extend the backend with cart checkouts or use DEXes to swap any incoming ERC20 tokens into your preferred currency. Gnosis Chain Arbitrum 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.