# Introduction to Nimiq Blockchain ## Lesson 1: Welcome to Nimiq Web Tutorial! --- type: lesson title: Welcome to Nimiq focus: /basics.js terminal: panels: ['terminal'] --- # Welcome to Nimiq Web Tutorial! **Nimiq** is a browser-native blockchain that lets you build decentralized applications without complex setup. No downloads, no installations - just JavaScript that connects directly to the blockchain! ## What Makes Nimiq Special? Most blockchains require you to either: - Download 100+ GB of data, or - Trust a third-party service to access the network **Nimiq is different!** You can connect directly to the blockchain with just a few lines of JavaScript: - **Browser-Native**: Works directly in web browsers - **No Trust Required**: Connect directly, no intermediaries - **Zero Knowledge Proofs**: Verify everything without downloading everything - **Lightweight**: Only downloads what you need ## What Makes This Tutorial Special? - **Zero setup required** - Everything runs in your browser - **Beginner-friendly** - No prior blockchain experience needed - **Hands-on learning** - Try code examples immediately - **Real blockchain interaction** - Connect to the actual Nimiq network ## What You'll Build By the end of this tutorial, you'll know how to: - ✅ **Connect to the Nimiq network** and read blockchain data - ✅ **Create wallets** and send transactions - ✅ **Work with staking** to earn rewards - ✅ **Create cashlinks** for shareable payments - ✅ **Build decentralized applications** that users can interact with ## What You Need to Know **Good news:** You DON'T need to understand: - Complex cryptography (Nimiq handles this) - Network protocols (handled automatically) - Consensus algorithms (built-in) **You DO need to understand:** - Async/await patterns (lots of network calls) - Event-driven programming (blockchain events) - Error handling (network can be unreliable) **New to Blockchain?** No problem! We'll explain everything as we go. ## Why Should I Care About Blockchain? As a developer, you're already familiar with: - **APIs** → Blockchain is like a decentralized API - **Databases** → Blockchain is an immutable database - **Authentication** → Blockchain provides cryptographic identity - **State management** → Blockchain manages global state Blockchain adds **trust** and **ownership** to these concepts. Instead of trusting a company's server, you trust math and cryptography. ## Real-World Comparison | Web Development | Blockchain Development | | --------------------- | ----------------------- | | REST API calls | Blockchain queries | | Database transactions | Blockchain transactions | | User authentication | Wallet signatures | | Server state | Network consensus | | API rate limits | No fees on Nimiq! | ## Common Developer Misconceptions - **"I need to understand Bitcoin first"** → Nimiq is different, start here! - **"Blockchain is too slow for real apps"** → Wrong! Only **1 second** transaction - **"I need to be a cryptography expert"** → The Web Client handles the hard parts - **"It's just for financial apps"** → It can be for decentralized logins, on-chain data, and automated systems. Think outside the box! ## Tutorial Overview 1. **Connecting to the Network**: Learn how to connect to Nimiq and explore the blockchain 2. **Working with Transactions**: Create wallets, request test funds, and send your first transaction 3. **Staking and Validators**: Understand Proof-of-Stake and earn rewards by delegating 4. **HTLC and Cashlinks**: Create shareable payment links using Hash Time Locked Contracts ## Let's Get Started! First, let's install the tutorial dependencies: ```bash npm install ``` Then try your first example: ```bash npm run basics ``` > Don't worry if you see errors in the console. They are completely fine. This will connect you to the Nimiq network and show you live blockchain data. Don't worry if you don't understand the code yet! We will talk you through it step by step. From now on, these commands will be run for you automatically. Simply change the code and the script will restart from the beginning, allowing you to focus on the tutorial. #### Other Examples You Can Try Once you're comfortable, explore these other examples: - `npm run transactions` - Send payments and manage wallets - `npm run staking` - Delegate tokens and earn rewards - `npm run htlc` - Create shareable payment links (cashlinks) ## About This Environment This tutorial runs in your browser using WebContainer — a genuine Node.js environment that requires no setup. You get: - an optimised experience for desktop - a working terminal (try it below!) - A complete file system - All the tools you need to build with Nimiq. The code you write here will work with any JavaScript environment, including Node.js and modern runtimes such as Deno and Bun. However, you will need to update the code for it to work in a browser. You can learn more about adapting the code for browsers here. ## Need Help? - 📖 [Complete Nimiq Documentation](https://nimiq.dev) - 🌐 [Learn more about Nimiq](https://nimiq.com) ## Bugs, Feedback and Ideas If you find something not working or any kind of feedback, please click `Feedback & Bugs` and let us know how we can improve this Tutorial. --- **Ready to build your first blockchain app?** ======================================== # Connecting to the Nimiq Network ## Lesson 1: Connecting to the Nimiq Network! 🌐 --- type: lesson title: Understanding Nimiq and Blockchain Basics focus: /package.json terminal: panels: ['output'] --- # Connecting to the Nimiq Network! 🌐 Welcome to your first hands-on lesson! In this part, you'll connect directly to the Nimiq blockchain and see it in action. ## What You'll Build By the end of this section, you'll have a working application that: - **Connects to the Nimiq network** - No third parties needed! - **Establishes consensus** - Syncs with the global blockchain - **Reads live blockchain data** - See real transactions and blocks - **Works in any environment** - Browser, Node.js, anywhere JavaScript runs ## Quick Preview Here's what your code will look like: ```javascript // 1. Import the Nimiq Client import { Client, ClientConfiguration } from '@nimiq/core' // 2. Configure the client for the testnet const config = new ClientConfiguration() config.network('TestAlbatross') // ... more configuration for seed nodes and sync mode ... // 3. Create the client and connect const client = await Client.create(config.build()) // 4. Wait for consensus to be established await client.waitForConsensusEstablished() console.log('✅ Consensus established!') // 5. Interact with the blockchain const headBlock = await client.getHeadBlock() console.log(`📊 Current block height: ${headBlock.height}`) ``` Simple, right? Let's build it step by step. ## Project Setup We'll build a simple app that connects to the Nimiq testnet (a safe playground version of the blockchain). Look at the `package.json` file. We've already configured everything you need: - `@nimiq/core` - The complete Nimiq Web Client - `"type": "module"` - Modern JavaScript support - Ready-to-run scripts Check the console — we have already run the commands `npm install` and `npm run dev` automatically. Just update the code and the script will run again with your changes. Now, update the code to print `'Hello Nimiq!'` in the console. > Tip: You can also click the 'Solve' button in the top right corner to view the solution. ## What We'll Code Next In the next step, we'll create an `index.js` file that: 1. **Imports the Nimiq client** 2. **Connects to the test network** 3. **Waits for consensus** 4. **Shows live blockchain data** You'll see real blockchain information appear in your terminal - blocks being created, transactions happening, the network staying in sync! The beauty of Nimiq is its simplicity. With just a few lines of code, you'll be talking directly to a global blockchain network. **No complex setup, no massive downloads, no third-party APIs needed.** Let's start coding! ### Solution Code #### index.js ```js console.log('Hello Nimiq!') ``` #### package.json ```json { "name": "nimiq-tutorial", "type": "module", "scripts": { "dev": "node --watch index.js" }, "dependencies": { "@nimiq/core": "latest" } } ``` --- ## Lesson 2: Your First Blockchain Connection! 🚀 --- type: lesson title: Implementing the Connection focus: /index.js terminal: panels: ['output'] --- # Your First Blockchain Connection! 🚀 Time to make the magic happen! In just a few lines of code, you'll be talking directly to the Nimiq blockchain. Let's build this step by step. ## What We're Building Look at the `index.js` file → You'll see we've started with the basic import: ```js import { Client, ClientConfiguration } from '@nimiq/core' ``` > Remember that if you are running this code in a browser, you will need to adapt it according to the instructions found here. By the end of this lesson, this file will: - ✅ Connect to the Nimiq testnet (a safe playground blockchain) - ✅ Establish consensus with thousands of other nodes worldwide - ✅ Show you live blockchain data in your terminal **Ready to connect to your first blockchain?** Let's do this! ## Step 1: Configure Your Client First, we need to tell Nimiq how to connect. Replace the first `TODO` comment with: ```js // Create a configuration object for network connection const config = new ClientConfiguration() ``` **What this does:** Creates a configuration object that will hold all our connection settings. Think of it like filling out a form before joining a network. ## Step 2: Choose Your Network Nimiq has two networks: - `TestAlbatross`: Safe playground with test tokens for learning. The **NIM token does not have any real value**. - `MainAlbatross`: The production network. The NIM token has **real value**. For this tutorial, we'll use the testnet where you can experiment safely. Replace the second `TODO` comment with: ```js // Connect to the test network for safe experimentation config.network('TestAlbatross') ``` **What this does:** Tells your client "I want to connect to the test network where I can experiment safely!" ## Step 3: Set Your Connection Points The testnet needs special connection points (seed nodes). These are like the "phone numbers" your client calls to join the network. Replace the third `TODO` comment with: ```js // Set multiple seed nodes for reliable testnet connection config.seedNodes([ '/dns4/seed1.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed2.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed3.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed4.pos.nimiq-testnet.com/tcp/8443/wss', ]) ``` **What this does:** Gives your client multiple entry points to the testnet. If one is busy, it tries another! > 💡 **Tip:** Mainnet doesn't need this step - it has built-in seed nodes! ## Step 4: Create Your Client Now let's turn our configuration into a working client! Replace the fourth `TODO` comment with: ```js // Create the client with our configuration const client = await Client.create(config.build()) ``` **What this does:** This is where the magic starts! Your client is born and ready to connect to the blockchain network. ## Step 5: Establish Consensus The final step - join the global network and sync with everyone else! Replace the fifth `TODO` comment with: ```js // Wait for consensus with the network await client.waitForConsensusEstablished() console.log('✅ Consensus established!') ``` **What this does:** Your client talks to other nodes, agrees on the current state of the blockchain, and becomes a fully participating member of the network! ## Pro Configuration Tips Want to make your connection even better? Add these optional configurations after Step 1: ```js // Super fast sync mode config.syncMode('pico') // Reduce log spam in terminal config.logLevel('error') ``` **Pico mode:** Downloads only essential data instead of everything. Perfect for apps that don't need the full blockchain history! ## What Happens When You Run This? When you execute this code, you'll see: 1. **Connection messages** - Your client reaching out to seed nodes 2. **Sync progress** - Downloading and verifying blockchain data 3. **Consensus established** - Success! You're connected! 4. **Your celebration message** - "🎉 Connected to Nimiq testnet!" ## The Magic You Just Performed Congratulations! You just: - Connected directly to a **global blockchain network** - Joined thousands of other nodes around the world - Established **cryptographic consensus** without trusting anyone - Did it all with **pure JavaScript** in seconds, not hours This is what makes Nimiq special - blockchain connections that are fast, simple, and work anywhere JavaScript runs! ### Solution Code #### index.js ```js import { Client, ClientConfiguration } from '@nimiq/core' async function main() { console.log('🚀 Starting Nimiq Web Client Tutorial') // Create a configuration builder const config = new ClientConfiguration() // Select Testnet config.network('TestAlbatross') // Set seed nodes config.seedNodes([ '/dns4/seed1.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed2.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed3.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed4.pos.nimiq-testnet.com/tcp/8443/wss', ]) // Print minimal messages config.logLevel('error') // Super fast sync mode config.syncMode('pico') // Instantiate and launch the client console.log('📡 Creating client and connecting to network...') const client = await Client.create(config.build()) // Wait for consensus to be established console.log('⏳ Waiting for consensus to be established...') await client.waitForConsensusEstablished() console.log('✅ Consensus established!') } main().catch(console.error) ``` #### package.json ```json { "name": "nimiq-tutorial", "type": "module", "scripts": { "dev": "node --watch index.js" }, "dependencies": { "@nimiq/core": "latest" } } ``` --- ## Lesson 3: Exploring Your Blockchain Connection! --- type: lesson title: Exploring Network Information focus: /index.js terminal: panels: ['output'] --- # Exploring Your Blockchain Connection! Great! You've successfully connected to the Nimiq network and established consensus. Now let's explore what information we can get from the network. ## What You Can Access Once consensus is established, your client has access to the current state of the blockchain. You're now connected to a global network of computers all maintaining the same digital ledger! ## Network Information Let's confirm which Nimiq network you're connected to: ```js const networkId = await client.getNetworkId() let networkName = 'unkwown' if (networkId === 5) networkName = 'testnet' else if (networkId === 6) networkName = 'mainnet' console.log(`🌐 Connected to ${networkName} network`) ``` This tells you exactly which version of Nimiq you're talking to - the testnet (for learning) or the main network. ## Head Block Information The **head block** is the latest block in the blockchain - think of it as the "latest news" from the network. Add this code to see current blockchain data: ```js // Get the latest block information const headBlock = await client.getHeadBlock() console.log(`🏔️ Block Height: ${headBlock.height}`) console.log(`🧱 Block Hash: ${headBlock.hash}`) console.log(`⏰ Block Time: ${new Date(headBlock.timestamp * 1000).toLocaleString()}`) ``` **What you're seeing:** - **Block Height**: How many blocks exist in the chain (like page numbers in a book) - **Block Hash**: A unique fingerprint that identifies this specific block - **Block Time**: When this block was created and added to the blockchain ### Listening for New Blocks Instead of asking for the latest block repeatedly, you can subscribe to get notified every time a new block is added to the chain. It's like getting a push notification for blockchain updates! ```js // Subscribe to new head blocks await client.addHeadChangedListener((hash) => { console.log(`🎉 New block mined! Hash: ${hash}`) }) ``` Now, your application will react in real-time as the blockchain grows. This is incredibly powerful for building dynamic and responsive dapps. ### Getting Full Block Details on Subscription But what if you need more than just the hash? You can combine what you've learned to get the full block data in real-time. Inside your listener, you can use the `getBlock()` method with the new `hash` to fetch the complete block details: ```js // Subscribe to new head blocks and get the full block const listenerId = await client.addHeadChangedListener(async (hash) => { const block = await client.getBlock(hash) console.log(`🎉 New block. Block number: ${block.height}. Hash: ${hash}`) }) console.log('👂 Subscribed to head changes and now getting full blocks!') ``` ## What This Means With these simple API calls, you're accessing live data from a global blockchain network: - **Global Consensus**: The block data you see is agreed upon by nodes worldwide - **Real-Time Updates**: This information updates as new blocks are created - **Decentralized Access**: You're reading directly from the blockchain, no middleman needed ### Solution Code #### index.js ```js import { Client, ClientConfiguration } from '@nimiq/core' async function main() { console.log('🚀 Starting Nimiq Web Client Tutorial') // Create a configuration builder const config = new ClientConfiguration() // We can also use `MainAlbatross` for mainnet config.network('TestAlbatross') // We must explicitly set the seed nodes for testnet config.seedNodes([ '/dns4/seed1.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed2.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed3.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed4.pos.nimiq-testnet.com/tcp/8443/wss', ]) // Connect using pico which is faster // Read more at: https://www.nimiq.com/developers/learn/protocol/sync-protocol/nodes-and-sync config.syncMode('pico') // Print minimal messages config.logLevel('error') // Instantiate and launch the client console.log('📡 Creating client and connecting to network...') const client = await Client.create(config.build()) // Wait for consensus to be established console.log('⏳ Waiting for consensus to be established...') await client.waitForConsensusEstablished() console.log('✅ Consensus established!') // Get the network ID const networkId = await client.getNetworkId() let networkName = 'unkwown' if (networkId === 5) networkName = 'testnet' else if (networkId === 6) networkName = 'mainnet' console.log(`🌐 Connected to ${networkName} network`) // Get and log head block information const headBlock = await client.getHeadBlock() console.log(`📊 Current block height: ${headBlock.height}`) console.log(`🧱 Head block hash: ${headBlock.hash}`) console.log(`⏰ Head block timestamp: ${new Date(headBlock.timestamp * 1000)}`) // Listen to new blocks and get the full block data await client.addHeadChangedListener(async (hash) => { const block = await client.getBlock(hash) console.log(`🎉 New block. Block number: ${block.height}. Hash: ${hash}`) }) } main().catch(console.error) ``` #### package.json ```json { "name": "nimiq-tutorial", "type": "module", "scripts": { "dev": "node --watch index.js" }, "dependencies": { "@nimiq/core": "latest" } } ``` ======================================== # Working with Transactions and Wallets ## Lesson 1: Creating Your First Nimiq Wallet! --- type: lesson title: Setting Up a Wallet focus: /index.js terminal: panels: ['output'] --- # Creating Your First Nimiq Wallet! Time to create your blockchain identity! A wallet is your gateway to the Nimiq network - it lets you send, receive, and manage your digital assets. ## What Makes a Nimiq Wallet Think of a wallet like a digital keychain: - **Private key**: Your secret that controls your funds (never share this!) - **Public key**: Derived from your private key, safe to share - **Address**: Your "account number" that others use to send you NIM - **Signing power**: Ability to authorize transactions and prove ownership ## Your First Wallet We'll create a brand new wallet and connect it to the testnet. Look at the `index.js` file - we're building on the connection code from the previous chapter. Let's create your blockchain identity step by step! ## Step 1: Generate Your Private Key This creates the foundation of your wallet - your unique secret key: ```js // Generate a secure random private key const privateKey = PrivateKey.generate() ``` **What this does:** Generates a cryptographically secure random private key. This is like creating a master password for your blockchain identity! ## Step 2: Create Your KeyPair Transform your private key into a working keypair: ```js // Derive public key from private key const keyPair = KeyPair.derive(privateKey) ``` **What this does:** Derives your public key from the private key using elliptic curve cryptography. Now you have both keys needed for blockchain operations! ## Step 3: Get Your Address Create your public blockchain address: ```js // Create your public blockchain address const address = keyPair.toAddress() ``` **What this does:** Generates your Nimiq address from your public key. This is what people will use to send you NIM - like your blockchain "email address"! ## Step 4: Display Your Wallet Identity Let's see your new blockchain identity: ```js // Display your wallet information console.log('🎉 Your new Nimiq wallet:') console.log('📍 Address:', address.toUserFriendlyAddress()) console.log('🔐 Public Key:', keyPair.publicKey.toHex()) ``` **What you'll see:** Your unique Nimiq address (starting with 'NQ') and your public key in hexadecimal format. ## Step 5: Check Your Balance Let's check if your wallet has any funds: ```js // Check your wallet balance const account = await client.getAccount(address.toUserFriendlyAddress()) console.log('📊 Account:', account) // Convert lunas to NIM (1 NIM = 100,000 lunas) const nim = account.balance / 1e5 console.log(`💰 Balance: ${nim} NIM`) ``` **What this does:** Queries the blockchain for your account information. Since this is a brand new wallet, you'll see 0 NIM - but that's about to change! In the Nimiq blockchain, the smallest unit is called a 'luna'. One NIM equals 100,000 lunas. > Tip: In JavaScript, you can use the number `1e5` to represent 100,000, i.e. one followed by five zeroes. ## Understanding Your Output When you run this code, you'll see: - **Your address**: Starts with 'NQ' - this is your blockchain identity! - **Your public key**: The cryptographic key others can use to verify your transactions - **Your balance**: Currently 0 NIM (we'll fix this in the next lesson!) - **Account details**: Complete information about your account on the blockchain ## Important: Your Wallet's Lifecycle Each time you run this code, you create a **completely new wallet** with a new address and keys. This means: - ✅ **Great for learning**: Safe to experiment with - ⚠️ **Remember**: Any funds will be "lost" when you restart (since you get a new address) **In real applications**, you'd save and load your private key securely. We'll cover proper wallet storage in advanced tutorials! ## What You Just Achieved! Congratulations! You now have: - **A unique blockchain identity** - **The ability to receive NIM** at your address - **A wallet that can sign transactions** - **Your first step** toward building payment applications ## Next: Getting Test Funds! In the next lesson, we'll get some free testnet NIM from the faucet to fund your wallet. Then you can start sending your first blockchain transactions! ### Solution Code #### consensus.js ```js /** * This file contains the functionality to connect to the Nimiq network. * You can learn how we do this in the tutorial "Connecting to the Network". */ import { Client, ClientConfiguration } from '@nimiq/core' /** * Create and connect to the Nimiq network * @param {string} network - Network to connect to ('TestAlbatross' or 'MainAlbatross') * @returns {Promise} - Connected Nimiq client */ export async function getClient(network = 'TestAlbatross') { console.log('🚀 Starting Nimiq client...') // Create client configuration const config = new ClientConfiguration() config.network(network) // We must explicitly set the seed nodes for testnet if (network === 'TestAlbatross') { config.seedNodes([ '/dns4/seed1.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed2.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed3.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed4.pos.nimiq-testnet.com/tcp/8443/wss', ]) } else if (network === 'MainAlbatross') { config.seedNodes([ '/dns4/aurora.seed.nimiq.com/tcp/443/wss', '/dns4/catalyst.seed.nimiq.network/tcp/443/wss', '/dns4/cipher.seed.nimiq-network.com/tcp/443/wss', '/dns4/eclipse.seed.nimiq.cloud/tcp/443/wss', '/dns4/lumina.seed.nimiq.systems/tcp/443/wss', '/dns4/nebula.seed.nimiq.com/tcp/443/wss', '/dns4/nexus.seed.nimiq.network/tcp/443/wss', '/dns4/polaris.seed.nimiq-network.com/tcp/443/wss', '/dns4/photon.seed.nimiq.cloud/tcp/443/wss', '/dns4/pulsar.seed.nimiq.systems/tcp/443/wss', '/dns4/quasar.seed.nimiq.com/tcp/443/wss', '/dns4/solstice.seed.nimiq.network/tcp/443/wss', '/dns4/vortex.seed.nimiq.cloud/tcp/443/wss', '/dns4/zenith.seed.nimiq.systems/tcp/443/wss', ]) } // Connect using pico which is faster // Read more at: https://www.nimiq.com/developers/learn/protocol/sync-protocol/nodes-and-sync config.syncMode('pico') // Print minimal messages config.logLevel('error') // Create the client instance const client = await Client.create(config.build()) console.log('⏳ Client created, waiting for consensus...') // Wait for consensus await client.waitForConsensusEstablished() console.log('✅ Consensus established!') return client } ``` #### index.js ```js import { KeyPair, PrivateKey } from '@nimiq/core' import { getClient } from './consensus.js' async function main() { console.log('🚀 Starting Nimiq client...') // Setup consensus (moved to separate file for clarity) const client = await getClient() // Generate a new wallet using PrivateKey.generate() const privateKey = PrivateKey.generate() // Create a KeyPair from the private key using KeyPair.derive() const keyPair = KeyPair.derive(privateKey) // Get the address from the keyPair using keyPair.toAddress() const address = keyPair.toAddress() // Log the user-friendly address and public key console.log('📍 Address:', address.toUserFriendlyAddress()) console.log('🔐 Public Key:', keyPair.publicKey.toHex()) // Check wallet balance using client.getAccount() const account = await client.getAccount(address.toUserFriendlyAddress()) console.log('📊 Account:', account) // Convert balance from lunas to NIM (divide by 1e5) and display const nim = account.balance / 1e5 console.log(`💰 Balance: ${nim} NIM`) } main() ``` #### package.json ```json { "name": "nimiq-wallet-tutorial", "type": "module", "scripts": { "dev": "node --watch index.js" }, "dependencies": { "@nimiq/core": "^2.0.0" } } ``` --- ## Lesson 2: Getting Your First Blockchain Funds! --- type: lesson title: Requesting Testnet Funds from Faucet focus: /index.js terminal: panels: ['output'] --- # Getting Your First Blockchain Funds! Your wallet is ready, but it's empty! Time to get some testnet NIM so you can start making transactions. We'll use the Nimiq faucet - a service that gives free testnet coins for developers. ## What's a Faucet? Think of a faucet as a "free money dispenser" for testing: - **Free testnet NIM** for development and learning - **No real value** - safe to experiment with - **Unlimited funds** - the faucet has infinite **testnet** NIM available! - **Multiple requests allowed** - you can get funds whenever you need them - **Developer-friendly** - helps you build without spending real money ## Your First Funding Request We'll make an HTTP request to the faucet API to fund your wallet. Look at the `index.js` file - we're building on the wallet code from the previous lesson. Let's get you some blockchain funds! ## Step 1: Create Your Faucet Request Function Add this function to handle the faucet request: ```js // Request free testnet funds from the faucet async function requestFundsFromFaucet(address) { console.log('💧 Requesting funds from faucet...') const response = await fetch(FAUCET_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ address: address.toUserFriendlyAddress(), withStackingContract: false }) }) // Check if request succeeded if (!response.ok) { throw new Error(`Faucet request failed with status: ${response.status}`) } const data = await response.json() console.log('💰 Faucet request successful!') return data } ``` **What this does:** Sends a POST request to the faucet with your wallet address, asking for free testnet NIM. The faucet will create a transaction to send funds to your address! ## Step 2: Request Your Funds After creating your wallet and checking the initial balance, add this call: ```js // Request funds from faucet await requestFundsFromFaucet(address) ``` **What this does:** Calls the faucet with your address. The faucet will queue a transaction to send you testnet NIM! ## Step 3: Wait and Check Your Balance Blockchain transactions take a moment to process. Let's wait and check if your funds arrived: ```js // Wait for blockchain to process the transaction console.log('⏳ Waiting for transaction to be processed...') await new Promise(resolve => setTimeout(resolve, 3000)) // Check balance again const updatedAccount = await client.getAccount(address.toUserFriendlyAddress()) const updatedNim = updatedAccount.balance / 1e5 console.log(`💰 Updated Balance: ${updatedNim} NIM`) if (updatedAccount.balance > 0) { console.log('✅ Funds received successfully!') } else { console.log('⏳ No funds received yet. Faucet transaction might still be processing.') } ``` **What this does:** Waits 3 seconds for the blockchain to process the transaction, then checks your balance again. You should see your first NIM appear! 🎉 ## What You'll Experience When you run this code, watch the magic happen: - ✅ **Empty wallet** - Starts with 0 NIM balance - ✅ **Faucet request** - Your app asks the faucet for funds - ✅ **Transaction processing** - The blockchain processes the payment - ✅ **Funds appear** - Your balance updates with free testnet NIM! You're about to receive your first blockchain transaction! 🌟 ## Unlimited Testnet Funds **Great news!** The faucet now has unlimited testnet NIM available. This means: - **No daily limits** - Request as many times as you need - **Instant availability** - Always ready for development - **Perfect for testing** - Build applications without worrying about running out of funds - **Multiple requests** - Great for testing scenarios that need multiple funded wallets Feel free to experiment and make multiple requests as you learn! ## What This Milestone Means Once you have testnet funds, you can: - Send your first blockchain transactions - Test payment flows in your applications - Experiment with different transaction types - Build real functionality without risk **You're about to have a funded blockchain wallet!** This is a huge step toward building real applications. ## Next: Creating Transactions! In the next lesson, we'll use these funds to create and sign your first blockchain transaction. You'll learn to send NIM from your wallet to another address! ### Solution Code #### consensus.js ```js /** * This file contains the functionality to connect to the Nimiq network. * You can learn how we do this in the tutorial "Connecting to the Network". */ import { Client, ClientConfiguration } from '@nimiq/core' /** * Create and connect to the Nimiq network * @param {string} network - Network to connect to ('TestAlbatross' or 'MainAlbatross') * @returns {Promise} - Connected Nimiq client */ export async function getClient(network = 'TestAlbatross') { console.log('🚀 Starting Nimiq client...') // Create client configuration const config = new ClientConfiguration() config.network(network) // We must explicitly set the seed nodes for testnet if (network === 'TestAlbatross') { config.seedNodes([ '/dns4/seed1.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed2.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed3.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed4.pos.nimiq-testnet.com/tcp/8443/wss', ]) } else if (network === 'MainAlbatross') { config.seedNodes([ '/dns4/aurora.seed.nimiq.com/tcp/443/wss', '/dns4/catalyst.seed.nimiq.network/tcp/443/wss', '/dns4/cipher.seed.nimiq-network.com/tcp/443/wss', '/dns4/eclipse.seed.nimiq.cloud/tcp/443/wss', '/dns4/lumina.seed.nimiq.systems/tcp/443/wss', '/dns4/nebula.seed.nimiq.com/tcp/443/wss', '/dns4/nexus.seed.nimiq.network/tcp/443/wss', '/dns4/polaris.seed.nimiq-network.com/tcp/443/wss', '/dns4/photon.seed.nimiq.cloud/tcp/443/wss', '/dns4/pulsar.seed.nimiq.systems/tcp/443/wss', '/dns4/quasar.seed.nimiq.com/tcp/443/wss', '/dns4/solstice.seed.nimiq.network/tcp/443/wss', '/dns4/vortex.seed.nimiq.cloud/tcp/443/wss', '/dns4/zenith.seed.nimiq.systems/tcp/443/wss', ]) } // Connect using pico which is faster // Read more at: https://www.nimiq.com/developers/learn/protocol/sync-protocol/nodes-and-sync config.syncMode('pico') // Print minimal messages config.logLevel('error') // Create the client instance const client = await Client.create(config.build()) console.log('⏳ Client created, waiting for consensus...') // Wait for consensus await client.waitForConsensusEstablished() console.log('✅ Consensus established!') return client } ``` #### faucet.js ```js /** * Request testnet NIM from the faucet * @param {Nimiq.Address} address - The address to send funds to * @returns {Promise} - Success status */ export async function requestFromFaucet(address) { const faucetUrl = 'https://faucet.pos.nimiq-testnet.com/tapit' try { console.log('💧 Requesting funds from faucet...') const response = await fetch(faucetUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: `address=${address.toUserFriendlyAddress()}`, }) if (response.ok) { console.log('✅ Faucet request successful!') return true } else { console.log('❌ Faucet request failed:', response.status) return false } } catch (error) { console.error('❌ Error requesting from faucet:', error.message) return false } } ``` #### index.js ```js import { KeyPair, PrivateKey } from '@nimiq/core' import { getClient } from './consensus.js' import { requestFromFaucet } from './faucet.js' console.log('🚀 Starting Nimiq client...') async function main() { try { // Setup consensus const client = await getClient() // Generate a new wallet const privateKey = PrivateKey.generate() const keyPair = KeyPair.derive(privateKey) const address = keyPair.toAddress() console.log('📍 Address:', address.toUserFriendlyAddress()) console.log('🔐 Public Key:', keyPair.publicKey.toHex()) // Check wallet balance 💰 const account = await client.getAccount(address.toUserFriendlyAddress()) console.log('📊 Account:', account) // Convert lunas to NIM. In this case, the balance is always 0 since we just created the wallet. const nim = account.balance / 1e5 console.log(`💰 Initial Balance: ${nim} NIM`) // Request funds from faucet await requestFromFaucet(address) // Wait for funds to arrive console.log('⏳ Waiting for transaction to be processed...') await new Promise(resolve => setTimeout(resolve, 3000)) // Check balance again const updatedAccount = await client.getAccount(address.toUserFriendlyAddress()) const updatedNim = updatedAccount.balance / 1e5 console.log(`💰 Updated Balance: ${updatedNim} NIM`) if (updatedAccount.balance > 0) { console.log('✅ Funds received successfully!') } else { console.log('⏳ No funds received yet. Faucet transaction might still be processing.') } } catch (error) { console.error('Error:', error) } } main() ``` #### package.json ```json { "name": "nimiq-faucet-tutorial", "type": "module", "version": "1.0.0", "scripts": { "dev": "node --watch index.js" }, "dependencies": { "@nimiq/core": "^2.0.0" } } ``` --- ## Lesson 3: Your First Blockchain Transaction! --- type: lesson title: Creating and Signing Transactions focus: /index.js terminal: panels: ['output'] --- # Your First Blockchain Transaction! This is the moment you've been building toward! You'll create, sign, and send your first transaction on the Nimiq blockchain. Let's turn your funded wallet into a transaction-sending powerhouse! ## What You'll Accomplish By the end of this lesson, you'll have: - ✅ **Sent your first blockchain transaction** - Real value moving on a global network - ✅ **Signed with cryptographic proof** - Your private key authorizing the transfer - ✅ **Created different transaction types** - Basic transfers and transactions with messages - ✅ **Mastered the complete flow** - From balance checking to transaction broadcasting ## Two Types of Transactions Nimiq supports different transaction types for different needs: #### Basic Transactions - **Simple value transfers** from A to B - **Most efficient and common** - **Perfect for payments** - **No fees!** #### Extended Transactions with Data - **Include custom messages** (up to 64 bytes) - **Great for receipts, notes, or metadata** - **Same transfer capability** with extra context - **No fees!** ## Transaction Essentials Every transaction needs these key components: - **Sender**: Your wallet address (where funds come from) - **Recipient**: Destination address (where funds go to) - **Value**: Amount to send in Luna (1 NIM = 100,000 Luna) - **Fee**: Transaction fee (currently 0 in Nimiq!) - **Valid Start Height**: Block height from which the transaction becomes valid - **Network ID**: Identifies the network (`TestAlbatross` for our tutorial) - **Data**: Optional message for extended transactions (up to 64 bytes) ## Getting Started We'll generate a fresh wallet and fund it using the testnet faucet. The faucet has unlimited testnet funds, we can easily request testnet NIM whenever we need it for development! ## Your Transaction Journey Here's what we'll build together: 1. **Generate Fresh Wallet** - Create a new random private key 2. **Request Testnet Funds** - Get NIM from the faucet 3. **Check Your Balance** - See how much you have to work with 4. **Find a Recipient** - Use transaction history to find an address 5. **Create Two Transactions** - One basic, one with a message 6. **Sign Both** - Authorize with your private key 7. **Send to Network** - Broadcast to the blockchain! ## Step 1: Generate Your Wallet and Request Funds ```js // Generate a fresh wallet const privateKey = PrivateKey.generate() const keyPair = KeyPair.derive(privateKey) const address = keyPair.toAddress() // Request funds from the faucet console.log('💧 Requesting funds from faucet...') await requestFromFaucet(client, address) // Wait for funds to arrive console.log('⏳ Waiting for funds to arrive...') await new Promise(resolve => setTimeout(resolve, 3000)) ``` **What this does:** Creates a brand new wallet and requests testnet NIM from the faucet. The faucet will send you funds for testing and development! ## Step 2: Check Your Current Balance Let's see what you have to work with: ```js // Check your current balance before sending const account = await client.getAccount(address.toUserFriendlyAddress()) console.log('💰 Current balance:', account.balance / 1e5, 'NIM') ``` **What this does:** Queries the blockchain for your current balance. You should see the testnet NIM you received from the faucet! ## Step 3: Find a Recipient Address We will look for the most recent address that sent funds to our wallet and return the funds to that address. ```js // Get transaction history to find a recipient address const txHistory = await client.getTransactionsByAddress(address) // Find the most recent incoming transaction const firstTx = txHistory.find(tx => tx.sender !== address.toUserFriendlyAddress()) const recipientAddress = Address.fromUserFriendlyAddress(firstTx.sender) ``` **What this does:** Gets your transaction history to find addresses that have sent you NIM (like the faucet). We'll use one of these as our recipient. ## Step 4: Create Your First Basic Transaction Let's create a simple value transfer: ```js const basicTx = TransactionBuilder.newBasic( address, // sender recipientAddress, // recipient BigInt(account.balance / 2), // value (half of balance) 0n, // fee (0 in Nimiq!) headBlock.height, // validity start height networkId, // testnet or mainnet ) ``` **What this does:** Creates a basic transaction that sends half your balance to another address. The `BigInt` ensures precise handling of the value! ## Step 5: Sign and Send the Basic Transaction Time to sign and send it: ```js basicTx.sign(keyPair) const basicTxHash = await client.sendTransaction(basicTx) console.log('✅ Basic transaction sent! Hash:', basicTxHash.serializedTx) ``` **What this does:** Your private key signs the transaction (proving you authorize them), then broadcasts them to the global network! ## Step 6: Create an Extended Transaction with Message Now let's create a transaction with a personal message: ```js // Create an extended transaction with a custom message const message = 'My first Nimiq transaction!' const messageBytes = new TextEncoder().encode(message) const extendedTx = TransactionBuilder.newBasicWithData( address, // sender recipientAddress, // recipient messageBytes, // data BigInt(account.balance / 2), // value (remaining half) 0n, // fee (0 in Nimiq!) headBlock.height, // validity start height networkId, // testnet or mainnet ) ``` **What this does:** Creates a transaction with a custom message! The recipient will see your message along with the funds. ## Step 7: Sign and Send Extended Transaction Time to authorize and broadcast: ```js extendedTx.sign(keyPair) const extendedTxHash = await client.sendTransaction(extendedTx) console.log('✅ Extended transaction sent:', extendedTxHash) ``` **What this does:** Your private key signs the transaction (proving you authorize them), then broadcasts them to the global network! ## What You'll See Happen When you run this code: 1. **Fresh Wallet** - New random private key generated 2. **Faucet Request** - Testnet NIM requested and received 3. **Balance Check** - See your current funds 4. **Transaction Creation** - Two transactions built and ready 5. **Digital Signatures** - Your private key authorizes the transfers 6. **Network Broadcast** - Transactions sent to the blockchain 7. **Transaction Hashes** - Unique IDs for tracking your transactions ## Key Concepts Mastered - **Zero Fees**: Nimiq's feeless transactions make micro-payments viable - **Digital Signatures**: Your private key proves ownership and authorization - **Transaction Hashes**: Unique identifiers for tracking transactions globally - **Faucet Usage**: Easy way to get testnet funds for development **You joined now the millions of blockchain transactions happening worldwide!** Your transactions will be permanently recorded on the Nimiq blockchain, verified by nodes around the globe. That's the power of decentralized networks! Don't trust, verify! ### Solution Code #### consensus.js ```js /** * This file contains the functionality to connect to the Nimiq network. * You can learn how we do this in the tutorial "Connecting to the Network". */ import { Client, ClientConfiguration } from '@nimiq/core' /** * Setup and establish consensus with the Nimiq network * This file handles the client initialization so the main index.js * can focus on the specific learning objectives */ export async function setupConsensus() { try { console.log('🚀 Setting up Nimiq client...') // Create client configuration const config = new ClientConfiguration() config.network('TestAlbatross') // Set seed nodes for testnet config.seedNodes([ '/dns4/seed1.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed2.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed3.pos.nimiq-testnet.com/tcp/8443/wss', '/dns4/seed4.pos.nimiq-testnet.com/tcp/8443/wss', ]) // Use pico sync for faster connection config.syncMode('pico') // Minimal logging config.logLevel('error') // Create and connect to the Nimiq network const client = await Client.create(config.build()) console.log('⏳ Client created, waiting for consensus...') // Wait for consensus to be established await client.waitForConsensusEstablished() console.log('✅ Consensus established!') return client } catch (error) { console.error('❌ Failed to establish consensus:', error) throw error } } ``` #### faucet.js ```js /** * Request testnet NIM from the faucet * @param {Nimiq.Client} client - The client to use to get the balance * @param {Nimiq.Address} address - The address to send funds to * @returns {Promise} - Success status */ export async function requestFromFaucet(client, address) { const faucetUrl = 'https://faucet.pos.nimiq-testnet.com/tapit' try { console.log('💧 Requesting funds from faucet...') const response = await fetch(faucetUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: `address=${address.toUserFriendlyAddress()}`, }) if (!response.ok) { console.log('❌ Faucet request failed:', response.status) return false } console.log('✅ Faucet request successful!') console.log('⏳ Waiting for funds to arrive...') await new Promise(resolve => setTimeout(resolve, 3_000)) const balance = await client.getBalance(address) console.log(`💰 Balance: ${balance / 1e5} NIM`) return true } catch (error) { console.error('❌ Error requesting from faucet:', error.message) return false } } ``` #### index.js ```js import { Address, KeyPair, PrivateKey, TransactionBuilder } from '@nimiq/core' import { requestFromFaucet } from './faucet.js' import { setupConsensus } from './lib/consensus.js' console.log('🚀 Starting Nimiq client...') async function main() { try { // Setup consensus (from previous lessons) const client = await setupConsensus() const headBlock = await client.getHeadBlock() console.log('📊 Current block height:', headBlock.height) const networkId = await client.getNetworkId() console.log('🌐 Network ID:', networkId) // Generate a new wallet const privateKey = PrivateKey.generate() const keyPair = KeyPair.derive(privateKey) const address = keyPair.toAddress() console.log('🎉 Wallet created!') console.log('📍 Address:', address.toUserFriendlyAddress()) // Request funds from faucet console.log('💧 Requesting funds from faucet...') await requestFromFaucet(client, address) // Wait for funds to arrive console.log('⏳ Waiting for funds to arrive...') await new Promise(resolve => setTimeout(resolve, 3000)) // Read the current balance of our account const account = await client.getAccount(address.toUserFriendlyAddress()) console.log('💰 Current balance:', account.balance / 1e5, 'NIM') // Get transaction history to find the sender of the first transaction (faucet) const txHistory = await client.getTransactionsByAddress(address) console.log(`📊 Found ${txHistory.length} transactions`) if (txHistory.length === 0) { console.log('❌ No transactions found in history') return } // Find the most recent transaction that is not from us (faucet transaction) const firstTx = txHistory.find(tx => tx.sender !== address.toUserFriendlyAddress()) const recipientAddress = Address.fromUserFriendlyAddress(firstTx.sender) console.log('🔍 Faucet address found:', recipientAddress.toUserFriendlyAddress()) // Calculate amounts for each transaction (half each) const halfBalance = BigInt(account.balance / 2) // Create a basic transaction sending half of the funds back to the faucet sender const basicTx = TransactionBuilder.newBasic( address, // sender recipientAddress, // recipient halfBalance, // value (half of balance) 0n, // fee (0 in Nimiq!) headBlock.height, // validity start height networkId, // testnet or mainnet ) console.log('📝 Basic transaction created:') console.log(' From:', basicTx.sender.toUserFriendlyAddress()) console.log(' To:', basicTx.recipient.toUserFriendlyAddress()) console.log(' Amount:', Number(basicTx.value) / 1e5, 'NIM') console.log(' Type: Basic') // Sign the basic transaction basicTx.sign(keyPair) console.log('✍️ Basic Transaction signed successfully!') // Send the basic transaction console.log('📤 Sending basic transaction...') const basicTxHash = await client.sendTransaction(basicTx) console.log('✅ Basic transaction sent! Hash:', basicTxHash.serializedTx) // Create an extended transaction with data sending the other half back const message = 'Nimiq is awesome!' const messageBytes = new TextEncoder().encode(message) const extendedTx = TransactionBuilder.newBasicWithData( address, // sender recipientAddress, // recipient messageBytes, // data halfBalance, // value (remaining half) 0n, // fee (0 in Nimiq!) headBlock.height, // validity start height networkId, // testnet or mainnet ) console.log('📝 Extended transaction created:') console.log(' From:', extendedTx.sender.toUserFriendlyAddress()) console.log(' To:', extendedTx.recipient.toUserFriendlyAddress()) console.log(' Amount:', Number(extendedTx.value) / 1e5, 'NIM') console.log(' Message:', message) console.log(' Type: Extended with Data') // Sign the extended transaction extendedTx.sign(keyPair) console.log('✍️ Extended Transaction signed successfully!') // Send the extended transaction console.log('📤 Sending extended transaction...') const extendedTxHash = await client.sendTransaction(extendedTx) console.log('✅ Extended transaction sent! Hash:', extendedTxHash.serializedTx) // Display summary console.log('\n🎉 Transaction Summary:') console.log('📝 Basic Transaction Hash:', basicTxHash.serializedTx) console.log('📝 Extended Transaction Hash:', extendedTxHash.serializedTx) console.log('💰 Total Amount Sent:', Number(basicTx.value + extendedTx.value) / 1e5, 'NIM') console.log('📤 Both transactions sent successfully!') } catch (error) { console.error('❌ Error:', error.message) } } main() ``` #### package.json ```json { "name": "nimiq-transaction-signing-tutorial", "type": "module", "version": "1.0.0", "scripts": { "dev": "node --watch index.js" }, "dependencies": { "@nimiq/core": "^2.0.0" } } ``` ======================================== # Staking and Validators ## Lesson 1: Staking Your NIM and Earning Rewards! --- type: lesson title: Understanding Staking and Validators focus: /index.js terminal: panels: ['output'] --- # Staking Your NIM and Earning Rewards! Welcome to the world of Proof-of-Stake (PoS)! You're about to learn how to make your NIM work for you by participating in network security and earning rewards. ## What You'll Learn In this lesson, you'll understand: - **What staking is** and how it secures the Nimiq network - The roles of **validators and stakers** - How to **earn rewards** by delegating your NIM - How to get a list of active validators to start your staking journey ## Quick Preview Here's a sneak peek at how you'll fetch the list of available validators: ```javascript // 1. Connect to the Nimiq Mainnet const client = await getClient('MainAlbatross') // 2. Define the staking contract address const STAKING_CONTRACT_ADDRESS = 'NQ77 0000 0000 0000 0000 0000 0000 0000 0001' // 3. Retrieve the staking contract's account data const contract = await client.getAccount(STAKING_CONTRACT_ADDRESS) // 4. Extract and display the list of active validators const activeValidators = contract.activeValidators.map(([address, balance]) => ({ address, balance: `${balance / 1e5} NIM` })) console.table(activeValidators) ``` This simple command gives you all the information you need to choose a validator to stake with. ## Key Staking Concepts Before we dive into the code, let's cover the basics. ### What Is Staking? Staking is the process of locking up your NIM to help secure the network. In return, you earn rewards. It's like a savings account that also supports the blockchain. - **Lock up NIM** to participate in network validation - **Earn rewards** for helping secure the network - **Support validators** who maintain the blockchain ### Validators and Stakers - **Validators** are nodes that run the network. They process transactions and create new blocks. They are required to stake a significant amount of NIM. - **Stakers** (like you!) are NIM holders who delegate their tokens to a validator. You share in the validator's rewards without needing to run your own hardware. ### The Reward System - Rewards are distributed automatically every epoch (a set period of time). - The more you stake, the more you earn. - Your rewards are also dependent on the validator's performance. ## Your Task Now, let's get your hands dirty. Your first step into the staking world is to find out which validators are available. To do this, you will query the Nimiq blockchain for the official staking contract. Let's use the Mainnet to see some real data! ### 1. Connect to Mainnet First, let's connect to the Nimiq Mainnet to work with real-world data. ```javascript const client = await getClient('MainAlbatross') ``` ### 2. The Staking Contract All staking operations in Nimiq are handled by a special smart contract called the **staking contract**. This contract holds information about validators, stakers, and their stakes. To get validator information, you first need its address. The address for the main staking contract is always: `NQ77 0000 0000 0000 0000 0000 0000 0000 0001` Define it as a constant in your code: ```javascript const STAKING_CONTRACT_ADDRESS = 'NQ77 0000 0000 0000 0000 0000 0000 0000 0001' ``` ### 3. Fetching the Contract Account With the address, you can use `client.getAccount()` to fetch the contract's current state. This is the same method you'd use to get any account's details. ```javascript const contract = await client.getAccount(STAKING_CONTRACT_ADDRESS) ``` ### 4. Finding the Active Validators The returned `contract` object contains a wealth of information. The list of active validators is in the `activeValidators` property. The data is structured as an array of tuples, where each tuple contains the validator's address and their total stake. ```javascript const activeValidators = contract.activeValidators ``` ### 5. Displaying the Validators To make the list easier to read, you can format it. The stake is in Luna (the smallest unit of NIM), so you'll want to convert it to NIM by dividing by 100,000. Using `console.table()` will give you a nicely formatted output. ```javascript const activeValidators = contract.activeValidators.map(([address, balance]) => ({ address, balance: `${balance / 1e5} NIM`, })) console.table(formattedValidators) ``` Put it all together to see the list of staking partners available to you! ### Solution Code #### index.js ```js import { getClient } from './lib/consensus.js' async function main() { try { // 1. Connect to the Nimiq Mainnet const client = await getClient('MainAlbatross') // 2. Define the staking contract address const STAKING_CONTRACT_ADDRESS = 'NQ77 0000 0000 0000 0000 0000 0000 0000 0001' // 3. Retrieve the staking contract's account data const contract = await client.getAccount(STAKING_CONTRACT_ADDRESS) // 4. Extract and display the list of active validators const activeValidators = contract.activeValidators.map(([address, balance]) => ({ address, balance: `${balance / 1e5} NIM`, })) console.table(activeValidators) } catch (error) { console.error('❌ Error:', error) } } main().catch(console.error) ``` --- ## Lesson 2: From Address to Identity --- type: lesson title: 'Exploring Validators: From Address to Identity' focus: /index.js terminal: panels: ['output'] --- # From Address to Identity In the last lesson, you successfully fetched a list of active validator addresses directly from the Nimiq staking contract. While this is a great first step, a raw address doesn't tell you much about a validator's performance, reliability, or community standing. How do you choose the best one to delegate your stake to? In this lesson, you'll level up your skills by enriching that on-chain data with valuable off-chain information from the [Nimiq Validators API](https://github.com/nimiq/validators-api). You'll learn how to combine these two data sources to create a comprehensive overview of each validator, including their name, fees, and the crucial **Validator Trust Score (VTS)**. ## What You'll Learn - How to fetch detailed validator metadata from an external API. - How to calculate estimated annual staking rewards. - The importance of the Validator Trust Score (VTS). - How to merge on-chain and off-chain data to make informed decisions. ## Your Mission: Build a Rich Validator List Your task is to create a script that fetches validator data from both the blockchain and the Nimiq Validators API, calculates potential rewards, and displays a sorted list of validators to help a user decide where to stake their NIM. Let's get started! ### 1. Fetching Validator Metadata First, you need data from the Nimiq Validators API. The `lib/validators-api.js` file already contains a helper function, `getValidators(network)`, which handles this for you. This function returns a detailed list of all validators, including their name, description, and fees. Add this to your `index.js`: ```javascript import { getValidators } from './lib/validators-api.js' // ... // Fetch additional data from Nimiq Validators API const validatorsData = await getValidators(network) ``` ### 2. Calculating Staking Rewards To estimate how much you could earn by staking, you need two key pieces of information: the validator's fee and the percentage of the total NIM supply that is currently staked. A higher staked supply generally means slightly lower rewards for everyone, as the total rewards are shared among more participants. The `lib/validators-api.js` file provides another helper, `getStakedSupplyRatio(network)`, to get this value. ```javascript import { getStakedSupplyRatio } from './lib/validators-api.js' // ... const stakedSupplyRatio = await getStakedSupplyRatio(network) ``` With the `stakedSupplyRatio` and the validator's `fee`, you can use the `calculateStakingRewards` function from the `@nimiq/utils` package to estimate your potential earnings. This powerful utility does the heavy lifting for you. Learn more about `@nimiq/utils` in the [documentation](https://www.nimiq.com/developers/build/nimiq-utils#nimiq-utils-js). ### 3. Merging On-Chain and Off-Chain Data Now it's time to combine the data. You'll map over the `activeValidators` array you retrieved from the staking contract. For each validator, you'll: 1. Find the corresponding metadata in `validatorsData`. 2. Calculate their total stake in NIM. 3. Use `calculateStakingRewards` to estimate the yearly reward percentage. 4. Construct a new object with all the combined information. ```javascript const enhancedValidators = contract.activeValidators.map(([address, balance]) => { const validatorInfo = validatorsData.find(v => v.address === address) || {} const totalStake = balance / 1e5 // Convert from Luna to NIM // Estimate yearly rewards ratio const { gainRatio } = calculateStakingRewards({ stakedSupplyRatio, days: 365, fee: validatorInfo.fee, }) return { 'name': validatorInfo.name || 'N/A', address, 'total stake (NIM)': totalStake.toFixed(2), 'yearly reward (%)': (gainRatio * 100).toFixed(2), 'fee %': validatorInfo.fee, 'vts': validatorInfo.score?.total || 0, } }) ``` ### 4. The Validator Trust Score (VTS) You might have noticed the `vts` field. This is the **Validator Trust Score**, a metric developed by the Nimiq team to assess the reliability and performance of validators based on factors like uptime and participation in the network. A higher score indicates a more trustworthy validator. It's an essential tool for making a good staking choice. The `vts` is a value between 0 and 1. For a deeper dive into how the score is calculated, you can read the [official documentation](https://www.nimiq.com/developers/validators/validator-trustscore). ### 5. Sort and Display Finally, to make the list easy to read, sort it by the Validator Trust Score in descending order and print it to the console using `console.table()`. This will present the most reputable validators at the top. ```javascript // Sort and display the final list enhancedValidators.sort((a, b) => b.vts - a.vts) console.table(enhancedValidators) ``` Run your script, and you'll see a beautiful, informative table of Nimiq validators, ready to help you make the best staking decision. ### Solution Code #### index.js ```js import { calculateStakingRewards } from '@nimiq/utils/rewards-calculator' import { getClient } from './lib/consensus.js' import { getStakedSupplyRatio, getValidators } from './lib/validators-api.js' async function main() { try { const network = 'MainAlbatross' // 1. Connect to the Nimiq Mainnet const client = await getClient(network) // 2. Define the staking contract address const STAKING_CONTRACT_ADDRESS = 'NQ77 0000 0000 0000 0000 0000 0000 0000 0001' // 3. Retrieve the staking contract's account data const contract = await client.getAccount(STAKING_CONTRACT_ADDRESS) // 4. Fetch additional data from Nimiq Validators API const validatorsData = await getValidators(network) // 5. Calculate total staked amount const stakedSupplyRatio = await getStakedSupplyRatio(network) // 6. Merge on-chain and off-chain data const enhancedValidators = contract.activeValidators.map(([address, balance]) => { const validatorInfo = validatorsData.find(v => v.address === address) || {} const totalStake = balance / 1e5 // Estimate yearly rewards ratio const { gainRatio } = calculateStakingRewards({ stakedSupplyRatio, days: 365, fee: validatorInfo.fee, }) return { 'name': validatorInfo.name || 'N/A', address, 'total stake (NIM)': totalStake.toFixed(2), 'yearly reward (%)': (gainRatio * 100).toFixed(2), 'fee %': validatorInfo.fee, 'vts': validatorInfo.score?.total || 0, } }) // 7. Sort and display the final list enhancedValidators.sort((a, b) => b.vts - a.vts) console.table(enhancedValidators) } catch (error) { console.error('❌ Error:', error) } } main().catch(console.error) ``` #### package.json ```json { "name": "nimiq-staking-tutorial", "type": "module", "version": "1.0.0", "description": "Learn about Nimiq staking and validators", "main": "index.js", "scripts": { "dev": "node --watch index.js" }, "dependencies": { "@nimiq/core": "^2.1.0", "@nimiq/utils": "^0.12.4" } } ``` --- ## Lesson 3: Staking --- type: lesson title: 'Staking' focus: /index.js terminal: panels: ['output'] --- # Staking We create new lessons in the order requested by the community. If you would like this one to go live sooner, click 'Feedback & Bugs' at the top of the page and send us a quick message — every vote shapes our roadmap! Want to help? Fork the repository and open a pull request at https://github.com/onmax/nimiq-tutorials. Community contributions speed things up for everyone. 😊 Thanks for your support! ======================================== # Advanced Features and Cashlinks ## Lesson 1: Cashlinks: Shareable Blockchain Payments --- type: lesson title: "Cashlinks: Shareable Blockchain Payments" previews: false focus: /index.js mainCommand: npm run dev prepareCommands: - npm install terminal: open: true activePanel: 0 panels: ['output'] --- # Cashlinks: Shareable Blockchain Payments We will learn how to create a Nimiq **cashlink**—a shareable payment link that lets anyone claim NIM from you, powered by the blockchain. Think of a cashlink like a digital gift card or a prepaid scratch-off ticket: you load it with value, share the link, and whoever receives it can "scratch off" (open the link) to claim the funds. No account or app needed—just the link! 🎁 --- ## What is a Cashlink? A **cashlink** is a special payment link that holds NIM (Nimiq's cryptocurrency) in a smart contract. Anyone with the link can claim the funds—perfect for gifts, rewards, or onboarding new users. Cashlinks use cryptography and smart contracts (HTLCs) to ensure only the recipient can unlock the funds. --- ## What You'll Learn - ✅ How to connect to the Nimiq blockchain using the Web Client - ✅ How to generate a secure wallet and get testnet NIM - ✅ How to create a cashlink (with a unique keypair) - ✅ How to fund the cashlink and monitor its status - ✅ How to build and share a cashlink URL --- We already prepared a basic script for you with a client that connects to the Nimiq network and a wallet with some funds (`keyPair`). The idea of the tutorial is that you will create a new Wallet with some funds on it. Then, you will generate a special link that contains the private key, so whoever opens the link can claim the funds for itself. ## Step 1: Creating the Cashlink Keypair A cashlink is powered by its own unique keypair. This keypair controls the funds until someone claims them. ```js import { KeyPair } from '@nimiq/core' const cashlinkKeyPair = KeyPair.generate() const cashlinkAddress = cashlinkKeyPair.toAddress() ``` **Why?** - The cashlink's private key is embedded in the link. Whoever opens the link can claim the funds. - The address is where you'll send the NIM. --- ## Step 2: Building the Cashlink URL The cashlink URL encodes the private key, amount, and message. We'll use a helper function for this: ```js import { BufferUtils, SerialBuffer } from '@nimiq/core' import { Utf8Tools } from '@nimiq/utils/utf8-tools' function getCashlinkUrl(network, cashlinkKeyPair, amount, message) { const hubDomain = network === 'MainAlbatross' ? 'https://hub.nimiq.com' : 'https://hub.nimiq-testnet.com' const messageBytes = Utf8Tools.stringToUtf8ByteArray(message) const buf = new SerialBuffer( cashlinkKeyPair.privateKey.serializedSize + 8 + (messageBytes.byteLength ? 1 : 0) + messageBytes.byteLength ) buf.write(cashlinkKeyPair.privateKey.serialize()) buf.writeUint64(amount) if (messageBytes.byteLength) { buf.writeUint8(messageBytes.byteLength) buf.write(messageBytes) } const hashUrl = BufferUtils .toBase64Url(buf) .replace(/\./g, '=') .replace(/\w{257,}/g, match => match.replace(/.{256}/g, '$&~')) return hashUrl } ``` As you can see we are using `Utf8Tools` from `@nimiq/utils` library. This library contains a lot of functionality that the Web Client does not. **What this does:** - Serializes the private key, amount, and message into a URL-safe string - This string is appended to the Nimiq Hub URL to create the cashlink Finally we create the `cashlinkUrl` in the `main` function: ```js const cashlinkUrl = getCashlinkUrl(network, cashlinkKeyPair, amount, message) ``` ## Step 3: Monitoring the Cashlink We can listen for transactions to see when the cashlink is funded and when it is claimed: ```js import { Address } from '@nimiq/core' async function setupCashlinkListeners(client, cashlinkAddress, senderAddress) { await client.addTransactionListener( (transaction) => { const recipient = Address.fromUserFriendlyAddress(transaction.recipient) const sender = Address.fromUserFriendlyAddress(transaction.sender) const isIncoming = recipient.equals(cashlinkAddress) const isOutgoing = sender.equals(cashlinkAddress) if (isIncoming) { console.log('\n💰 CASH LINK FUNDED!') console.log(`├─ Amount: ${transaction.value / 1e5} NIM`) console.log(`├─ From: ${transaction.sender}`) console.log(`├─ To: ${transaction.recipient}`) console.log(`└─ Transaction Hash: ${transaction.transactionHash}`) } if (isOutgoing) { console.log('\n🔓 CASH LINK CLAIMED!') console.log(`├─ Amount: ${transaction.value / 1e5} NIM`) console.log(`├─ From: ${transaction.sender}`) console.log(`├─ To: ${transaction.recipient}`) console.log(`└─ Transaction Hash: ${transaction.transactionHash}`) } }, [cashlinkAddress, senderAddress] ) console.log('📡 Monitoring for cashlink funding and claiming...\n') } ``` **What this does:** - Listens for transactions involving the cashlink address - Notifies you when the cashlink is funded or claimed - Helps you debug any issues you might find Then you need to trigger the function from the `main` function: ```js await setupCashlinkListeners(client, cashlinkAddress, senderAddress) ``` --- ## Step 4: Funding the Cashlink Now, let's send NIM to the cashlink address. Funding the Cashlink is a simply as sending some NIM to the new wallet we have created: ```js import { AccountType, Transaction, TransactionFlag } from '@nimiq/core' async function fundCashlink(client, keyPair, cashlinkAddress, amount) { const headBlock = await client.getHeadBlock() const networkId = await client.getNetworkId() const tx = new Transaction( keyPair.toAddress(), AccountType.Basic, new Uint8Array(0), cashlinkAddress, AccountType.Basic, [0, 132, 128, 146, 135], // Extra data for cashlink funding BigInt(amount), 0n, TransactionFlag.None, headBlock.height, networkId, ) tx.sign(keyPair) const txHash = await client.sendTransaction(tx) return txHash } ``` **What this does:** - Creates and signs a transaction to fund the cashlink - Uses special extra data to mark it as a cashlink - Sends the transaction to the blockchain Now you need to trigger the function from the `main` function together with some logs: ```js const tx = await fundCashlink(client, keyPair, cashlinkAddress, amount) console.log('\n🎉 Cashlink created successfully!') console.log(`\n\n 👉 Transaction Hash: ${tx.transactionHash}`) console.log(`\n\n 👉 Share or open this URL with someone to let them claim ${amount / 1e5} NIM!\n${cashlinkUrl}\n`) console.log('\n⏰ Keep this script running to see when the cashlink is claimed...') ``` --- ## Step 5: Claim your Cashlink Now, once the script is running you will see a URL linking to the Nimiq Hub. You can open the link and click `Claim`. Once it is complete come back to this tutorial to see how the console prints the success message! --- ## Summary - You generated a cashlink keypair and built a cashlink URL - You funded the cashlink and set up listeners to monitor its status - You can now share the cashlink URL—anyone with the link can claim the NIM! ## Your tasks Now here are some ideas you can try: 1. Change the amount or message 2. Create a custom Cashlink ### Custom Cashlink You can customize the Cashlinks with a custom theme: ```js const CashlinkTheme = { Unspecified: 0, Standard: 1, Christmas: 2, LunarNewYear: 3, Easter: 4, Generic: 5, Birthday: 6, } const theme = CashlinkTheme.Birthday // In our example we will do a birthday ``` Then in you need to update `getCashlinkUrl` to add the `theme` to the Cashlink URL: ```js function getCashlinkUrl(network, cashlinkKeyPair, amount, message) { const hubDomain = network === 'MainAlbatross' ? 'https://hub.nimiq.com' : 'https://hub.nimiq-testnet.com' const messageBytes = Utf8Tools.stringToUtf8ByteArray(message) const buf = new SerialBuffer( cashlinkKeyPair.privateKey.serializedSize // key + 8 // value + (messageBytes.byteLength || theme ? 1 : 0) + messageBytes.byteLength + (theme ? 1 : 0) ) buf.write(cashlinkKeyPair.privateKey.serialize()) buf.writeUint64(amount) if (messageBytes.byteLength || theme) { buf.writeUint8(messageBytes.byteLength) buf.write(messageBytes) } if (theme) buf.writeUint8(theme) const hashUrl = BufferUtils .toBase64Url(buf) .replace(/\./g, '=') // replace trailing . by = because of URL parsing issues on iPhone. .replace(/\w{257,}/g, match => match.replace(/.{256}/g, '$&~')) // break long words by adding a ~ every 256 characters return `${hubDomain}/cashlink/#${hashUrl}` } ``` Finally we update the message that we want to add to the cashlink: ```js const message = 'Happy Birthday! 🥳' ``` Now, when you open the Nimiq Hub, you will see a custom style for your Cashlink! --- ## Want to Create Cashlinks in Bulk? Ready to take your cashlink skills to the next level? For advanced use cases like creating hundreds of cashlinks for events, marketing campaigns, or bulk payments, check out the **Nimiq Cashlink Generator** - a powerful command-line tool that can: - 🔄 Generate and manage cashlinks in bulk - 📊 Create detailed statistics on cashlink usage - 🎨 Generate QR codes and printable vouchers - 💰 Fund and claim multiple cashlinks efficiently - 📈 Track cashlink performance and analytics 👉 **[Explore the Nimiq Cashlink Generator](https://github.com/nimiq/cashlink-generator)** This tool is perfect for businesses, developers, and anyone who needs to create cashlinks at scale. Go ahead and explore the advanced features that can help you build sophisticated cashlink campaigns! ### Solution Code #### index.js ```js import { AccountType, Address, BufferUtils, KeyPair, SerialBuffer, Transaction, TransactionFlag } from '@nimiq/core' import { Utf8Tools } from '@nimiq/utils/utf8-tools' import { setupConsensus } from './lib/consensus.js' import { getFundedWallet } from './lib/wallet.js' function getCashlinkUrl(network, cashlinkKeyPair, amount, message) { const hubDomain = network === 'MainAlbatross' ? 'https://hub.nimiq.com' : 'https://hub.nimiq-testnet.com' const messageBytes = Utf8Tools.stringToUtf8ByteArray(message) const buf = new SerialBuffer( cashlinkKeyPair.privateKey.serializedSize // key + 8 // value + (messageBytes.byteLength ? 1 : 0) // message or not message + messageBytes.byteLength, // message length ) buf.write(cashlinkKeyPair.privateKey.serialize()) buf.writeUint64(amount) if (messageBytes.byteLength) { buf.writeUint8(messageBytes.byteLength) buf.write(messageBytes) } const hashUrl = BufferUtils .toBase64Url(buf) .replace(/\./g, '=') // replace trailing . by = because of URL parsing issues on iPhone. .replace(/\w{257,}/g, match => match.replace(/.{256}/g, '$&~')) // break long words by adding a ~ every 256 characters return `${hubDomain}/cashlink/#${hashUrl}` } const CashlinkExtraData = { Funding: [0].concat('CASH'.split('').map(c => c.charCodeAt(0) + 63)), Claiming: [0].concat('LINK'.split('').map(c => c.charCodeAt(0) + 63)), } async function fundCashlink(client, keyPair, cashlinkAddress, amount) { const headBlock = await client.getHeadBlock() const networkId = await client.getNetworkId() const tx = new Transaction( keyPair.toAddress(), // sender AccountType.Basic, // account type new Uint8Array(0), // sender data cashlinkAddress, // recipient AccountType.Basic, // account type CashlinkExtraData.Funding, // message BigInt(amount), 0n, // fee TransactionFlag.None, headBlock.height, networkId, ) tx.sign(keyPair) const txHash = await client.sendTransaction(tx) return txHash } /** * Sets up transaction listeners for cashlink funding and claiming */ async function setupCashlinkListeners(client, cashlinkAddress, senderAddress) { await client.addTransactionListener( (transaction) => { const recipient = Address.fromUserFriendlyAddress(transaction.recipient) const sender = Address.fromUserFriendlyAddress(transaction.sender) const isIncoming = recipient.equals(cashlinkAddress) const isOutgoing = sender.equals(cashlinkAddress) if (isIncoming) { console.log('\n💰 CASH LINK FUNDED!') console.log(`├─ Amount: ${transaction.value / 1e5} NIM`) console.log(`├─ From: ${transaction.sender}`) console.log(`├─ To: ${transaction.recipient}`) console.log(`└─ Transaction Hash: ${transaction.transactionHash}`) } if (isOutgoing) { console.log('\n🔓 CASH LINK CLAIMED!') console.log(`├─ Amount: ${transaction.value / 1e5} NIM`) console.log(`├─ From: ${transaction.sender}`) console.log(`├─ To: ${transaction.recipient}`) console.log(`└─ Transaction Hash: ${transaction.transactionHash}`) } }, [cashlinkAddress, senderAddress], // Listen to both addresses ) console.log('📡 Monitoring for cashlink funding and claiming...\n') } async function main() { try { const network = 'TestAlbatross' const client = await setupConsensus(network) const keyPair = await getFundedWallet(client) const senderAddress = keyPair.toAddress() const amount = 1e5 // 1 NIM in Luna const message = 'Enjoy your NIM!' const hubDomain = network === 'MainAlbatross' ? 'https://hub.nimiq.com' : 'https://hub.nimiq-testnet.com' // Generate a new keypair for the cashlink const cashlinkKeyPair = KeyPair.generate() const cashlinkAddress = cashlinkKeyPair.toAddress() // Generate the cashlink URL const cashlinkUrl = getCashlinkUrl(network, cashlinkKeyPair, amount, message) // Set up transaction listeners await setupCashlinkListeners(client, cashlinkAddress, senderAddress) // Fund the cashlink const tx = await fundCashlink(client, keyPair, cashlinkAddress, amount) // Print the cashlink URL console.log('\n🎉 Cashlink created successfully!') console.log(`\n\n 👉 Transaction Hash: ${tx.transactionHash}`) console.log(`\n\n 👉 Share or open this URL with someone to let them claim ${amount / 1e5} NIM!\n${cashlinkUrl}\n`) console.log('\n⏰ Keep this script running to see when the cashlink is claimed...') } catch (error) { console.error('❌ Error:', error.message) } } main() ``` #### package.json ```json { "name": "nimiq-cashlink-creator", "type": "module", "version": "1.0.0", "description": "Create your first Nimiq cashlinks", "main": "index.js", "scripts": { "dev": "node --watch index.js" }, "dependencies": { "@nimiq/core": "latest", "@nimiq/utils": "latest" } } ``` ======================================== # Polygon Basics ## Lesson 1: Introduction to Polygon --- type: lesson title: "Introduction to Polygon" focus: /index.js mainCommand: npm run demo prepareCommands: - npm install terminal: open: true activePanel: 0 panels: ['output'] --- # Introduction to Polygon Before starting this section, you should have: - Completed Sections 1–4 of this tutorial - Basic understanding of EVM wallets and blockchain transactions - MetaMask or similar wallet extension installed ## Learning Objectives By the end of this section, you will be able to: - Set up and connect a wallet to the Polygon network (testnet and mainnet) - Distinguish between native POL tokens and ERC-20 tokens like USDC - Interact with ERC-20 token contracts on Polygon - Send USDC transactions on Polygon testnet - Verify transactions on Polygonscan block explorer --- Welcome to Polygon Basics! Over the next three lessons you will assemble a complete transaction workflow on Polygon: create wallets, fund them with test assets, and move both native POL and ERC20 tokens. Each lesson flows step by step so you can focus on the concepts rather than deciphering shorthand. The Nimiq Wallet supports stablecoins on EVM chains. The concepts you learn here (wallets, providers, POL gas, ERC20) are the foundation for Part 6 where we implement Nimiq‑style gasless transfers with OpenGSN. --- > **Prerequisites & Safety** > > - Use a fresh test wallet. Don't reuse mainnet or exchange keys. > - You will add a `PRIVATE_KEY` in a `.env` file in Lesson 2; no setup is needed yet. > - Faucets provide the test tokens you will need later; nothing to do in this introduction. > **Terminology** > > - **POL**: Polygon's native token (formerly MATIC). Gas is paid in POL. > - **EVM**: Ethereum Virtual Machine; the execution environment used by Polygon. > - **ERC20**: Token standard for fungible tokens (for example, USDC has 6 decimals). > - **Provider**: An RPC endpoint your code connects to (for example, `https://rpc-amoy.polygon.technology`). ## Why Polygon? **Polygon** is an Ethereum Layer 2 network designed to feel familiar while solving Ethereum's biggest pain points. - **Same developer experience**: It speaks the Ethereum Virtual Machine (EVM), so tools like ethers.js, Hardhat, or MetaMask work without modification. - **Lower transaction costs**: Fees are measured in fractions of a cent instead of whole dollars. - **Faster confirmations**: Blocks land roughly every two seconds, keeping interactions snappy. - **Ecosystem interoperability**: Assets and dApps can bridge between Polygon and Ethereum, so knowledge transfers directly. Think of Polygon as Ethereum's faster, more affordable sibling that still shares the family DNA. ### Note on Polygon's Native Token Polygon's native token was rebranded from **MATIC** to **POL** in 2024. - **On Polygon mainnet:** Use POL (contract not applicable; native asset) - **On Polygon testnet (Amoy):** Use test POL from the faucet - **In code references:** You may see both names in older documentation; they refer to the same asset This tutorial uses **POL** throughout. --- ## Meet Polygon Amoy For this section we use **Polygon Amoy**, the current Polygon testnet. It mirrors mainnet behavior with valueless test tokens, which makes it ideal for experimentation. - **Network Name**: Polygon Amoy Testnet - **Chain ID**: 80002 - **RPC URL**: https://rpc-amoy.polygon.technology - **Block Explorer**: https://amoy.polygonscan.com - **Native Token**: POL (pays gas fees) Because test tokens on Amoy are free, you can try ideas, make mistakes, and rerun scripts without worrying about real money. --- ## What You Will Build By the end of this part, you will have a working toolkit for everyday Polygon development: ### Polygon Wallet Setup & Faucets - Generate an Ethereum-compatible wallet with ethers.js. - Connect that wallet to Polygon Amoy. - Collect free POL and USDC from public faucets. - Read balances programmatically so you can verify funding. ### Sending POL Transactions - Craft and broadcast native POL transfers. - Inspect gas usage and confirmation receipts. - Follow the transaction lifecycle on PolygonScan. ### ERC20 Tokens & USDC Transfers - Review the ERC20 interface and why it matters. - Interact with token contracts through ABIs. - Send USDC and account for its six decimal places. Each lesson builds on the previous one, so keep your project files handy as you progress. --- ## Why These Skills Matter Mastering Polygon translates directly to the broader EVM ecosystem: - Mainnet Ethereum and Layer 2 networks such as Optimism, Arbitrum, and Base share the same patterns. - Sidechains like BNB Chain or Avalanche use identical wallet and contract workflows. - Any project that relies on ethers.js or web3.js expects these fundamentals. Once you are comfortable on Polygon, you can approach most EVM-based platforms with confidence. --- ## The Demo Script The code bundled with this lesson is a complete end-to-end walkthrough of everything you will build in Lessons 2-4. The demo runs automatically when you open this lesson—just check the terminal output. Treat it as a living reference: - **Review the terminal** to see the final experience in action. - **Copy individual snippets** as you implement each step in the subsequent lessons. - **Compare your work** against the finished version if you get stuck. The script demonstrates how to: 1. Create a wallet and connect to Polygon Amoy. 2. Check POL and USDC balances. 3. Send POL to another address. 4. Transfer USDC (an ERC20 token) safely. > 💡 **Heads-up**: You will still need faucet funds before the demo shows non-zero balances. Lesson 2 covers that process. Until then, you will see warnings about missing tokens. ## What the Demo Shows - The terminal prints a wallet address and environment details. - Balances start at zero until you use faucets in Lesson 2. - You will see warnings about missing tokens; they are expected. - The editor opens `/index.js`. Feel free to skim it; you do not need to change anything in this lesson. --- ## Next Up Continue to **Polygon Wallet Setup & Faucets** to create your first Polygon wallet and stock it with testnet tokens. ### Solution Code #### index.js ```js import { ethers } from 'ethers' import { createWalletFromPassword } from './lib/wallet.js' const RPC_URL = 'https://rpc-amoy.polygon.technology' const USDC_ADDRESS = '0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582' const USDC_ABI = [ 'function transfer(address to, uint256 amount) returns (bool)', 'function balanceOf(address account) view returns (uint256)', 'function decimals() view returns (uint8)', ] // 🔐 WALLET SETUP: Change this to your own unique password! // Same password = same wallet address every time (great for tutorials) const WALLET_PASSWORD = 'change_me_to_something_unique_like_pizza_unicorn_2024' // 📤 RECIPIENT: This is a Nimiq-controlled account that collects tutorial demo transfers // If you accidentally send large amounts or want your funds back, contact us and we'll return them! // // 💡 Want to send to yourself instead? Uncomment these lines to create a second wallet: // const recipientWallet = createWalletFromPassword('my_second_wallet_password_banana_2024') // const RECIPIENT = recipientWallet.address // This way you control both wallets and can recover any funds sent! // // Or create your own wallet with a standard 24-word mnemonic (see lib/wallet.js) const RECIPIENT = '0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184' async function main() { console.log('🚀 Polygon Basics - Complete Demo\n') console.log('This demo shows all concepts from lessons 2-4:\n') // ========== LESSON 2: WALLET SETUP & FAUCETS ========== console.log('📚 LESSON 2: Wallet Setup & Faucets') console.log('─'.repeat(50)) const provider = new ethers.providers.JsonRpcProvider(RPC_URL) // Create wallet from password (see lib/wallet.js for alternatives) const wallet = createWalletFromPassword(WALLET_PASSWORD).connect(provider) console.log('✅ Wallet created from password') if (WALLET_PASSWORD === 'change_me_to_something_unique_like_pizza_unicorn_2024') { console.log('⚠️ Using default password! Change WALLET_PASSWORD to your own unique string.') } console.log('📍 Address:', wallet.address) console.log('🔗 View on explorer:', `https://amoy.polygonscan.com/address/${wallet.address}`) // Check balances const polBalance = await provider.getBalance(wallet.address) console.log('💰 POL Balance:', ethers.utils.formatEther(polBalance), 'POL') const usdc = new ethers.Contract(USDC_ADDRESS, USDC_ABI, wallet) const usdcBalance = await usdc.balanceOf(wallet.address) const decimals = await usdc.decimals() console.log('💵 USDC Balance:', ethers.utils.formatUnits(usdcBalance, decimals), 'USDC') // Convert to 24-word mnemonic so you can import into any wallet const mnemonic = ethers.Wallet.fromMnemonic( ethers.utils.entropyToMnemonic(ethers.utils.hexZeroPad(wallet.privateKey, 32)), ).mnemonic.phrase console.log('\n📝 Mnemonic (24 words):', mnemonic) console.log('💡 You can import this mnemonic into any wallet to check your balances:') console.log(' • Nimiq Testnet Wallet: https://wallet.nimiq-testnet.com/ (supports Amoy USDC)') console.log(' • Note: Nimiq Wallet does not support POL, only USDC/USDT') console.log(' • Or use MetaMask, Trust Wallet, etc.\n') if (polBalance.eq(0)) { console.log('\n⚠️ No POL found! Get free tokens from:') console.log(' POL & USDC: https://faucet.polygon.technology/') console.log(' USDC also: https://faucet.circle.com/') console.log(' Then run this demo again!\n') return } // ========== LESSON 3: SENDING POL ========== console.log('\n📚 LESSON 3: Sending POL Transactions') console.log('─'.repeat(50)) const POL_AMOUNT = '0.0001' // Minimal amount for demo console.log('📤 Sending', POL_AMOUNT, 'POL to', RECIPIENT) try { // Get current gas price and ensure minimum for Polygon const feeData = await provider.getFeeData() const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas.lt(ethers.utils.parseUnits('30', 'gwei')) ? ethers.utils.parseUnits('30', 'gwei') : feeData.maxPriorityFeePerGas // Ensure maxFeePerGas is higher than maxPriorityFeePerGas const maxFeePerGas = feeData.maxFeePerGas.lt(maxPriorityFeePerGas) ? maxPriorityFeePerGas.mul(2) : feeData.maxFeePerGas const tx = await wallet.sendTransaction({ to: RECIPIENT, value: ethers.utils.parseEther(POL_AMOUNT), maxPriorityFeePerGas, maxFeePerGas, }) console.log('⏳ Transaction hash:', tx.hash) const receipt = await tx.wait() console.log('✅ Confirmed in block:', receipt.blockNumber) console.log('⛽ Gas used:', receipt.gasUsed.toString()) console.log('🔗 View:', `https://amoy.polygonscan.com/tx/${tx.hash}`) // Show balance change const newPolBalance = await provider.getBalance(wallet.address) const spent = polBalance.sub(newPolBalance) console.log('📊 Total spent:', ethers.utils.formatEther(spent), 'POL (including gas)') } catch (error) { console.log('❌ Failed:', error.message) } // ========== LESSON 4: ERC20 & USDC ========== console.log('\n📚 LESSON 4: ERC20 Tokens & USDC Transfers') console.log('─'.repeat(50)) if (usdcBalance.eq(0)) { console.log('⚠️ No USDC found! Get free USDC from:') console.log(' https://faucet.polygon.technology/') console.log(' https://faucet.circle.com/') console.log(' Then try this section again!\n') return } const USDC_AMOUNT = '0.01' // Minimal amount for demo console.log('📤 Sending', USDC_AMOUNT, 'USDC to', RECIPIENT) try { // Get current gas price and ensure minimum for Polygon const feeData = await provider.getFeeData() const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas.lt(ethers.utils.parseUnits('30', 'gwei')) ? ethers.utils.parseUnits('30', 'gwei') : feeData.maxPriorityFeePerGas // Ensure maxFeePerGas is higher than maxPriorityFeePerGas const maxFeePerGas = feeData.maxFeePerGas.lt(maxPriorityFeePerGas) ? maxPriorityFeePerGas.mul(2) : feeData.maxFeePerGas const amountInBaseUnits = ethers.utils.parseUnits(USDC_AMOUNT, decimals) const tx = await usdc.transfer(RECIPIENT, amountInBaseUnits, { maxPriorityFeePerGas, maxFeePerGas, }) console.log('⏳ Transaction hash:', tx.hash) const receipt = await tx.wait() console.log('✅ Confirmed in block:', receipt.blockNumber) console.log('🔗 View:', `https://amoy.polygonscan.com/tx/${tx.hash}`) // Show balance change const newUsdcBalance = await usdc.balanceOf(wallet.address) console.log('📊 New USDC balance:', ethers.utils.formatUnits(newUsdcBalance, decimals), 'USDC') const recipientBalance = await usdc.balanceOf(RECIPIENT) console.log('📊 Recipient USDC:', ethers.utils.formatUnits(recipientBalance, decimals), 'USDC') // Show POL used for gas (still needed for ERC20!) const finalPolBalance = await provider.getBalance(wallet.address) console.log('⛽ POL balance:', ethers.utils.formatEther(finalPolBalance), 'POL (gas paid in POL!)') } catch (error) { console.log('❌ Failed:', error.message) } console.log('\n🎉 Demo complete! Now try lessons 2-4 step by step.') } main().catch(console.error) ``` #### package.json ```json { "name": "polygon-basics-demo", "type": "module", "version": "1.0.0", "scripts": { "demo": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } ``` --- ## Lesson 2: Polygon Wallet Setup & Faucets --- type: lesson title: "Polygon Wallet Setup & Faucets" focus: /index.js mainCommand: npm run dev prepareCommands: - npm install terminal: open: true activePanel: 0 panels: ['output'] --- # Polygon Wallet Setup & Faucets Welcome to your first hands-on Polygon exercise. Before you can broadcast transactions or interact with smart contracts, you need two building blocks: a wallet that can sign messages and a source of test tokens. This lesson guides you through both with the same balance of narrative and structure used in the earlier chapters. --- ## Learning Goals By the end of this lesson you will: - **Generate an Ethereum-compatible wallet** with ethers.js and understand the difference between its private key and public address. - **Connect the wallet to Polygon Amoy**, Polygon's public testnet. - **Collect free POL** to pay for gas in upcoming lessons. - **Collect free USDC** so you can practice ERC20 transfers later. - **Store sensitive credentials safely** using environment variables. --- ## Why It Matters Wallets sit at the heart of every Web3 interaction. Whether you are experimenting with DeFi, NFTs, or the gasless payments you will build in Section 6, you must be able to: - Create and back up keypairs responsibly. - Talk to an RPC endpoint for the network you target. - Verify balances before submitting transactions. - Reuse credentials across scripts without exposing them. Master these fundamentals now, and everything that follows will feel natural. --- ## Polygon Amoy Recap We will work on **Polygon Amoy**, a no-stakes environment that mirrors Polygon mainnet: - **Network**: Polygon Amoy Testnet - **Native Token**: POL (covers gas fees) - **RPC URL**: https://rpc-amoy.polygon.technology - **Chain ID**: 80002 Because test tokens on Amoy have zero real-world value, you can experiment freely and rerun scripts as often as you like. --- ## Step 1: Create a Wallet A wallet consists of a private key (keep it secret) and the public address you can share. Use ethers.js to generate both in one line: ```js import { ethers } from 'ethers' // Create a random wallet const wallet = ethers.Wallet.createRandom() console.log('🔑 Your New Wallet') console.log('├─ Address:', wallet.address) console.log('└─ Private Key:', wallet.privateKey) ``` > ⚠️ **Security Note**: Logging private keys is acceptable in a controlled tutorial with test funds, but never do this in production or with real assets. --- ## Step 2: Connect to Polygon Amoy Next, connect the wallet to an RPC endpoint so it can read state and submit transactions. ```js const RPC_URL = 'https://rpc-amoy.polygon.technology' const provider = new ethers.providers.JsonRpcProvider(RPC_URL) const connectedWallet = wallet.connect(provider) ``` - `provider` handles network communication. - `connectedWallet` binds your wallet to that provider, so signing and broadcasting are ready to go. --- ## Step 3: Check Your Balance Fresh wallets start empty, but it is good practice to confirm that expectation programmatically. ```js const balance = await provider.getBalance(wallet.address) console.log('\n💰 Balance:', ethers.utils.formatEther(balance), 'POL') ``` You should see `0.0 POL`, confirming the wallet has not been funded yet. --- ## Step 4: Claim Free POL from the Faucet Faucets distribute play tokens for testnets. Follow these steps to fund your wallet with POL: 1. Copy the address printed in your console. 2. Visit the Polygon faucet at https://faucet.polygon.technology/. 3. Choose "Polygon Amoy" from the dropdown. 4. Paste your address and submit the request. Within roughly 30 seconds, the faucet should confirm the transfer. Run your script again to verify that the POL balance increased. You can also check your address on the explorer: https://amoy.polygonscan.com/address/ > Faucet tips > > If the faucet rate-limits you, wait a few minutes and retry. Make sure “Polygon Amoy” is selected. Some public faucets require sign-in or a CAPTCHA. --- ## Step 5: Claim Free USDC Later lessons rely on an ERC20 token, so grab some USDC while you are here. You can get USDC from either of these faucets: **Option 1: Polygon Faucet** (also gives POL) 1. Visit https://faucet.polygon.technology/. 2. Choose "Polygon Amoy" from the dropdown. 3. Paste your wallet address and submit. **Option 2: Circle Faucet** 1. Open https://faucet.circle.com/. 2. Select "Polygon Amoy" as the network. 3. Paste your wallet address. 4. Complete the CAPTCHA and submit. To inspect your USDC balance you must query the token contract directly: > Note on test tokens > > USDC testnet addresses can change. If the address returns zero unexpectedly, verify the current Amoy USDC address from official sources (faucet/docs) before assuming a code issue. ```js const USDC_ADDRESS = '0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582' const USDC_ABI = [ 'function balanceOf(address) view returns (uint256)', 'function decimals() view returns (uint8)' ] const usdc = new ethers.Contract(USDC_ADDRESS, USDC_ABI, provider) const usdcBalance = await usdc.balanceOf(wallet.address) const decimals = await usdc.decimals() console.log('\n💵 USDC Balance:', ethers.utils.formatUnits(usdcBalance, decimals), 'USDC') ``` --- ## Step 6: Store the Private Key Securely Persist your wallet so future lessons can reuse it. Create a `.env` file and add your key: ```bash PRIVATE_KEY=your_private_key_here ``` Load it in your script before creating the wallet instance: ```js import dotenv from 'dotenv' dotenv.config() const wallet = new ethers.Wallet(process.env.PRIVATE_KEY) ``` > 💡 **Pro Tip**: Add `.env` to `.gitignore` so sensitive keys never end up in version control. --- ## Wrap-Up You now have everything required for real Polygon workflows: - ✅ A reusable Ethereum-compatible wallet. - ✅ POL to cover transaction fees on Polygon Amoy. - ✅ USDC for ERC20 experiments. - ✅ Environment variable management for safe credential storage. In the next lesson, you will send your first on-chain POL transfer and watch it confirm in real time. ### Solution Code #### .env.example ```example # Save your private key here after Step 6 PRIVATE_KEY= ``` #### index.js ```js import { ethers } from 'ethers' import { createWalletFromPassword } from './lib/wallet.js' const RPC_URL = 'https://rpc-amoy.polygon.technology' const USDC_ADDRESS = '0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582' const USDC_ABI = ['function balanceOf(address) view returns (uint256)', 'function decimals() view returns (uint8)'] // 🔐 IMPORTANT: Use the SAME password you chose in lesson 1! // Same password = same wallet address = your funds will be there const WALLET_PASSWORD = 'change_me_to_something_unique_like_pizza_unicorn_2024' async function main() { // Step 1: Create wallet from your password (same as lesson 1) const wallet = createWalletFromPassword(WALLET_PASSWORD) console.log('🔑 Your Wallet (from password)') console.log('├─ Address:', wallet.address) console.log('└─ Password:', WALLET_PASSWORD) if (WALLET_PASSWORD === 'change_me_to_something_unique_like_pizza_unicorn_2024') { console.log('\n⚠️ Using default password! Change WALLET_PASSWORD to match lesson 1.') } // Step 2: Connect to Polygon Amoy const provider = new ethers.providers.JsonRpcProvider(RPC_URL) const connectedWallet = wallet.connect(provider) // Step 3: Check POL balance const balance = await provider.getBalance(wallet.address) console.log('\\n💰 POL Balance:', ethers.utils.formatEther(balance), 'POL') // Step 5: Check USDC balance const usdc = new ethers.Contract(USDC_ADDRESS, USDC_ABI, provider) const usdcBalance = await usdc.balanceOf(wallet.address) const decimals = await usdc.decimals() console.log('💵 USDC Balance:', ethers.utils.formatUnits(usdcBalance, decimals), 'USDC') console.log('\\n💡 Save this private key to .env file for future lessons!') } main().catch(console.error) ``` #### package.json ```json { "name": "polygon-wallet-setup", "type": "module", "version": "1.0.0", "scripts": { "dev": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } ``` --- ## Lesson 3: Sending POL Transactions --- type: lesson title: "Sending POL Transactions" focus: /index.js mainCommand: npm run send prepareCommands: - npm install terminal: open: true activePanel: 0 panels: ['output'] --- # Sending POL Transactions With your wallet funded, it is time to perform a live transaction on Polygon Amoy. Understand the concept, walk through each step deliberately, and then experiment on your own. --- ## Essential Context: POL **POL** (formerly called MATIC) is Polygon's native currency. Every on-chain action pays a small amount of POL as a gas fee, which compensates validators for processing your transaction. - **Token type**: Native protocol asset, similar to ETH on Ethereum. - **Gas currency**: All Polygon fees are charged in POL. - **Decimals**: 18 (same as ETH). Keep a small buffer of POL in your wallet; even ERC20 transfers consume it for gas. > 💡 **Nimiq contrast:** Nimiq blockchain has zero transaction fees. No gas token, no fee calculations—just send. We will revisit this advantage in Section 6 when we tackle gasless transactions! --- ## Step 1: Load the Wallet Reuse the `.env`-backed wallet you created earlier so you are not juggling multiple keys. ```js import dotenv from 'dotenv' import { ethers } from 'ethers' dotenv.config() const RPC_URL = 'https://rpc-amoy.polygon.technology' const provider = new ethers.providers.JsonRpcProvider(RPC_URL) const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider) console.log('🔑 Sender Address:', wallet.address) ``` --- ## Step 2: Confirm Available POL Always confirm you have enough POL to cover the transfer plus gas. ```js const balance = await provider.getBalance(wallet.address) console.log('💰 Balance:', ethers.utils.formatEther(balance), 'POL') ``` Aim for at least `0.1 POL`. If you are low, revisit the faucet before continuing. --- ## Step 3: Prepare the Transfer Choose a recipient address (another wallet you control works well) and decide how much to send. ```js const RECIPIENT = '0x...' // Replace with any address const AMOUNT_POL = '0.0001' // Minimal amount for demo console.log('\n📤 Preparing Transaction') console.log('├─ To:', RECIPIENT) console.log('└─ Amount:', AMOUNT_POL, 'POL') ``` --- ## Step 4: Broadcast the Transaction Let ethers.js handle signing and broadcasting with a single call. ```js const tx = await wallet.sendTransaction({ to: RECIPIENT, value: ethers.utils.parseEther(AMOUNT_POL) }) console.log('\n⏳ Transaction Sent!') console.log('├─ Hash:', tx.hash) console.log('└─ Waiting for confirmation...') ``` - `sendTransaction` creates and submits the transaction. - `parseEther` converts human-readable POL into wei, the smallest unit. - The transaction hash is your receipt number; keep it handy. --- ## Step 5: Wait for Confirmation Transactions settle once they are included in a block. Wait for that confirmation before moving on. ```js const receipt = await tx.wait() console.log('\n✅ Transaction Confirmed!') console.log('├─ Block:', receipt.blockNumber) console.log('├─ Gas Used:', receipt.gasUsed.toString()) console.log('├─ Status:', receipt.status === 1 ? 'Success' : 'Failed') console.log('└─ View on Explorer:', `https://amoy.polygonscan.com/tx/${tx.hash}`) ``` Open the explorer link to see the transaction exactly as validators processed it. --- ## Step 6: Reconcile Balances Confirm both the transfer amount and the gas cost deducted from your wallet. ```js const newBalance = await provider.getBalance(wallet.address) const spent = balance.sub(newBalance) console.log('\n📊 Balance Update') console.log('├─ Before:', ethers.utils.formatEther(balance), 'POL') console.log('├─ After:', ethers.utils.formatEther(newBalance), 'POL') console.log('└─ Spent:', ethers.utils.formatEther(spent), 'POL (including gas)') ``` The difference between the sent amount and the total spent reflects the gas fee. --- ## Understanding Gas Fees Every transfer has two cost components: 1. **Amount transferred**: The value delivered to the recipient. 2. **Gas fee**: The computational cost paid to validators. ``` Gas Fee = Gas Used × Gas Price ``` - **Gas Used** represents the work done (a simple transfer is roughly 21,000 units). - **Gas Price** fluctuates with network demand. On Polygon Amoy, these fees are tiny, but cultivating the habit of checking them now will pay off on higher-cost networks. --- ## Try-It Ideas - Send POL to yourself to see how the receipt looks when sender and recipient match. - Experiment with smaller or larger amounts and observe how gas usage stays consistent. - Submit multiple transactions in a row and compare their block numbers and confirmation times. --- ## Wrap-Up You have now: - ✅ Broadcast your first Polygon transaction. - ✅ Observed the full confirmation lifecycle. - ✅ Calculated how gas fees affect balances. - ✅ Gained confidence working with the POL native token. Next, you will apply the same discipline to ERC20 tokens by sending USDC. ### Solution Code #### .env.example ```example PRIVATE_KEY=your_private_key_from_lesson_1 RECIPIENT=0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184 ``` #### index.js ```js import { ethers } from 'ethers' import { createWalletFromPassword } from './lib/wallet.js' const RPC_URL = 'https://rpc-amoy.polygon.technology' const AMOUNT_POL = '0.0001' // 🔐 Use the SAME password from lesson 1 to access your wallet! const WALLET_PASSWORD = 'change_me_to_something_unique_like_pizza_unicorn_2024' // Recipient address (Nimiq-controlled - see lesson 1 for details) const RECIPIENT = '0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184' async function main() { // Step 1: Load wallet from your password const provider = new ethers.providers.JsonRpcProvider(RPC_URL) const wallet = createWalletFromPassword(WALLET_PASSWORD).connect(provider) console.log('🔑 Sender Address:', wallet.address) // Step 2: Check balance const balance = await provider.getBalance(wallet.address) console.log('💰 Balance:', ethers.utils.formatEther(balance), 'POL') // Step 3: Set up transaction console.log('\\n📤 Preparing Transaction') console.log('├─ To:', RECIPIENT) console.log('└─ Amount:', AMOUNT_POL, 'POL') // Step 4: Send transaction with proper gas settings // Get current gas price and ensure minimum for Polygon const feeData = await provider.getFeeData() const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas.lt(ethers.utils.parseUnits('30', 'gwei')) ? ethers.utils.parseUnits('30', 'gwei') : feeData.maxPriorityFeePerGas // Ensure maxFeePerGas is higher than maxPriorityFeePerGas const maxFeePerGas = feeData.maxFeePerGas.lt(maxPriorityFeePerGas) ? maxPriorityFeePerGas.mul(2) : feeData.maxFeePerGas const tx = await wallet.sendTransaction({ to: RECIPIENT, value: ethers.utils.parseEther(AMOUNT_POL), maxPriorityFeePerGas, maxFeePerGas, }) console.log('\\n⏳ Transaction Sent!') console.log('├─ Hash:', tx.hash) console.log('├─ Explorer:', `https://amoy.polygonscan.com/tx/${tx.hash}`) console.log('└─ Waiting for confirmation...') // Step 5: Wait for confirmation const receipt = await tx.wait() console.log('\\n✅ Transaction Confirmed!') console.log('├─ Block:', receipt.blockNumber) console.log('├─ Gas Used:', receipt.gasUsed.toString()) console.log('├─ Status:', receipt.status === 1 ? 'Success' : 'Failed') console.log('└─ View on Explorer:', `https://amoy.polygonscan.com/tx/${tx.hash}`) // Step 6: Check updated balances const newBalance = await provider.getBalance(wallet.address) const spent = balance.sub(newBalance) console.log('\\n📊 Balance Update') console.log('├─ Before:', ethers.utils.formatEther(balance), 'POL') console.log('├─ After:', ethers.utils.formatEther(newBalance), 'POL') console.log('└─ Spent:', ethers.utils.formatEther(spent), 'POL (including gas)') } main().catch(console.error) ``` #### package.json ```json { "name": "sending-pol", "type": "module", "version": "1.0.0", "scripts": { "send": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } ``` --- ## Lesson 4: ERC20 Tokens & USDC Transfers --- type: lesson title: "ERC20 Tokens & USDC Transfers" focus: /index.js mainCommand: npm run send prepareCommands: - npm install terminal: open: true activePanel: 0 panels: ['output'] --- # ERC20 Tokens & USDC Transfers Native POL transfers are only half the story on Polygon. The majority of assets you will handle live inside smart contracts that follow the ERC20 standard. In this lesson, you will apply everything you learned about wallets and providers to interact with **USDC**, a widely used stablecoin on Polygon Amoy. --- ## ERC20 Primer **ERC20** defines a common interface that every compliant token must expose. Once you know the standard method names, you can work with almost any token: - `transfer(to, amount)` moves tokens between addresses. - `balanceOf(address)` reports the balance for a specific holder. - `approve(spender, amount)` grants another address permission to move tokens on your behalf. - `decimals()` reveals how many decimal places the token uses. USDC conforms to this interface with the following Polygon Amoy details: - **Token address**: `0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582` - **Decimals**: 6 (so 1 USDC equals 1,000,000 base units) --- ## POL vs. USDC at a Glance | Feature | POL | USDC | | --------------- | -------------------------- | ------------------------------------------ | | Asset type | Native protocol token | ERC20 smart contract | | Primary purpose | Pay gas fees | Represent and transfer dollar-pegged value | | Transfer call | `wallet.sendTransaction()` | `contract.transfer()` | | Decimal places | 18 | 6 | | Who pays gas | Sender (in POL) | Still the sender (in POL) | Understanding this table clarifies why sending USDC feels slightly different even though the wallet and provider setup stays the same. --- ## Step 1: Load the Wallet and Set Up the Contract ```js import dotenv from 'dotenv' import { ethers } from 'ethers' dotenv.config() const RPC_URL = 'https://rpc-amoy.polygon.technology' const USDC_ADDRESS = '0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582' const provider = new ethers.providers.JsonRpcProvider(RPC_URL) const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider) console.log('🔑 Sender:', wallet.address) ``` --- ## Step 2: Describe the Contract with an ABI The Application Binary Interface (ABI) tells ethers.js which functions you plan to call. ```js const USDC_ABI = [ 'function transfer(address to, uint256 amount) returns (bool)', 'function balanceOf(address account) view returns (uint256)', 'function decimals() view returns (uint8)' ] const usdc = new ethers.Contract(USDC_ADDRESS, USDC_ABI, wallet) ``` > 💡 We only include the fragments we need. PolygonScan hosts the full ABI if you ever require additional functions. --- ## Step 3: Inspect Your USDC Balance Verify you have tokens to send before initiating a transfer. ```js const balance = await usdc.balanceOf(wallet.address) const decimals = await usdc.decimals() console.log('💵 USDC Balance:', ethers.utils.formatUnits(balance, decimals), 'USDC') ``` `formatUnits` respects custom decimal counts; using `formatEther` here would incorrectly assume 18 decimals. --- ## Step 4: Transfer USDC ERC20 transfers call the contract directly instead of using `sendTransaction`. ```js const RECIPIENT = process.env.RECIPIENT const AMOUNT = '0.01' // Minimal amount for demo console.log('\n📤 Sending USDC') console.log('├─ To:', RECIPIENT) console.log('└─ Amount:', AMOUNT, 'USDC') const amountInBaseUnits = ethers.utils.parseUnits(AMOUNT, decimals) const tx = await usdc.transfer(RECIPIENT, amountInBaseUnits) console.log('\n⏳ Transaction sent:', tx.hash) const receipt = await tx.wait() console.log('✅ Confirmed in block:', receipt.blockNumber) console.log('🔗 View:', `https://amoy.polygonscan.com/tx/${tx.hash}`) ``` Key differences from the POL workflow: - `usdc.transfer` submits a smart contract call. - Gas is still charged in POL, not USDC. - `parseUnits` uses the token's decimal count to avoid over- or under-paying. --- ## Step 5: Reconcile Balances After confirmation, check that both your USDC and POL balances have changed as expected. ```js const newBalance = await usdc.balanceOf(wallet.address) const recipientBalance = await usdc.balanceOf(RECIPIENT) console.log('\n📊 Updated Balances') console.log('├─ Your USDC:', ethers.utils.formatUnits(newBalance, decimals)) console.log('└─ Recipient USDC:', ethers.utils.formatUnits(recipientBalance, decimals)) // Check POL was spent on gas const polBalance = await provider.getBalance(wallet.address) console.log('\n⛽ Gas paid in POL:', ethers.utils.formatEther(polBalance)) ``` You should see: - Your USDC balance drops by the transfer amount. - Your POL balance dips slightly from gas costs. - The recipient's USDC balance increases accordingly. --- ## Gas Costs for ERC20 Transfers ERC20 transfers invoke smart contract logic, so they use more gas than native transfers: - Native POL transfer: about 21,000 gas. - ERC20 transfer: typically 50,000 to 65,000 gas. Polygon’s low fees mean the difference is small, but it is important to keep in mind on higher-cost networks. --- ## Practice Suggestions - Try sending different amounts (for example 0.1 USDC or 10 USDC) and confirm the decimals stay accurate. - Transfer tokens to your own address to see how the transaction appears in the logs. - Execute several transfers and compare gas usage across each receipt. --- ## Common Pitfalls ❌ Using `parseEther` for USDC will multiply the amount by 10^12 and likely fail or drain your balance. ```js // WRONG (sends 1,000,000,000,000 USDC) usdc.transfer(to, ethers.utils.parseEther('1')) // CORRECT (sends 1 USDC) usdc.transfer(to, ethers.utils.parseUnits('1', 6)) ``` ❌ Forgetting to keep POL on hand for gas will cause the transaction to revert. ❌ Skipping the balance check may leave you guessing why a transfer failed. --- ## Wrap-Up You now know how to: - ✅ Interact with ERC20 contracts through ethers.js. - ✅ Send USDC on Polygon Amoy with precise decimal handling. - ✅ Track both token balances and POL gas consumption. Next, in Part 6, you will learn how to cover those gas costs on behalf of your users with gasless transactions. ### Solution Code #### .env.example ```example PRIVATE_KEY=your_private_key_from_lesson_1 RECIPIENT=0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184 ``` #### index.js ```js import { ethers } from 'ethers' import { createWalletFromPassword } from './lib/wallet.js' const RPC_URL = 'https://rpc-amoy.polygon.technology' const USDC_ADDRESS = '0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582' const AMOUNT = '0.01' // 🔐 Use the SAME password from lesson 1 to access your wallet! const WALLET_PASSWORD = 'change_me_to_something_unique_like_pizza_unicorn_2024' // Recipient address (Nimiq-controlled - see lesson 1 for details) const RECIPIENT = '0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184' async function main() { // Step 1: Load wallet from your password const provider = new ethers.providers.JsonRpcProvider(RPC_URL) const wallet = createWalletFromPassword(WALLET_PASSWORD).connect(provider) console.log('🔑 Sender:', wallet.address) // Step 2: Connect to USDC contract const USDC_ABI = ['function transfer(address to, uint256 amount) returns (bool)', 'function balanceOf(address account) view returns (uint256)', 'function decimals() view returns (uint8)'] const usdc = new ethers.Contract(USDC_ADDRESS, USDC_ABI, wallet) // Step 3: Check balance const decimals = await usdc.decimals() const balance = await usdc.balanceOf(wallet.address) console.log('💵 USDC Balance:', ethers.utils.formatUnits(balance, decimals), 'USDC') // Step 4: Send USDC console.log('\\n📤 Sending USDC') console.log('├─ To:', RECIPIENT) console.log('└─ Amount:', AMOUNT, 'USDC') // Get current gas price and ensure minimum for Polygon const feeData = await provider.getFeeData() const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas.lt(ethers.utils.parseUnits('30', 'gwei')) ? ethers.utils.parseUnits('30', 'gwei') : feeData.maxPriorityFeePerGas // Ensure maxFeePerGas is higher than maxPriorityFeePerGas const maxFeePerGas = feeData.maxFeePerGas.lt(maxPriorityFeePerGas) ? maxPriorityFeePerGas.mul(2) : feeData.maxFeePerGas const amountInBaseUnits = ethers.utils.parseUnits(AMOUNT, decimals) const tx = await usdc.transfer(RECIPIENT, amountInBaseUnits, { maxPriorityFeePerGas, maxFeePerGas, }) console.log('\\n⏳ Transaction sent!') console.log('├─ Hash:', tx.hash) console.log('├─ Explorer:', `https://amoy.polygonscan.com/tx/${tx.hash}`) console.log('└─ Waiting for confirmation...') const receipt = await tx.wait() console.log('\\n✅ Confirmed in block:', receipt.blockNumber) // Step 5: Verify balances const newBalance = await usdc.balanceOf(wallet.address) const recipientBalance = await usdc.balanceOf(RECIPIENT) console.log('\\n📊 Updated Balances') console.log('├─ Your USDC:', ethers.utils.formatUnits(newBalance, decimals)) console.log('└─ Recipient USDC:', ethers.utils.formatUnits(recipientBalance, decimals)) const polBalance = await provider.getBalance(wallet.address) console.log('\\n⛽ Gas paid in POL:', ethers.utils.formatEther(polBalance)) } main().catch(console.error) ``` #### package.json ```json { "name": "erc20-usdc", "type": "module", "version": "1.0.0", "scripts": { "send": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } ``` ======================================== # Gasless Transfers ## Lesson 1: Mainnet Wallet Setup --- type: lesson title: "Mainnet Wallet Setup" focus: /index.js mainCommand: npm run generate prepareCommands: - npm install terminal: open: true activePanel: 0 panels: ['output'] --- # Mainnet Wallet Setup Before working with gasless transactions on Polygon mainnet, you need a wallet with real funds. This lesson generates a new wallet and shows you how to secure it properly. --- ## Why Mainnet? Unlike Section 5, which used the Amoy testnet, this section requires **Polygon mainnet**. OpenGSN relay networks operate on mainnet where real economic incentives keep relays running. The patterns you learn here translate directly to production apps. --- ## Generate Your Wallet Run the script to create a random wallet. You will receive three pieces of information: 1. **Address**: Your public identifier on Polygon. 2. **Private Key**: A 64-character hex string that controls your funds. 3. **24-Word Mnemonic**: A human-readable backup phrase. ```js import { ethers } from 'ethers' const wallet = ethers.Wallet.createRandom() console.log('Address:', wallet.address) console.log('Private Key:', wallet.privateKey) console.log('Mnemonic:', wallet.mnemonic.phrase) ``` --- ## Security First ⚠️ **CRITICAL**: Anyone with your private key or mnemonic can spend your funds. **Do:** - Store both in a password manager. - Write the mnemonic on paper and keep it safe. - Never share them with anyone. **Don't:** - Screenshot or email your credentials. - Store them in plain text on your computer. - Reuse this wallet for large amounts in production. > This is a learning wallet. Keep only enough funds to complete the tutorial (~5 USDT and 0.1 POL). --- ## Import into Nimiq Wallet (Optional) For a better UX, import your 24-word mnemonic into the Nimiq Wallet: 1. Visit https://wallet.nimiq.com 2. Select "Import with Recovery Words" 3. Paste your 24-word phrase 4. Access your wallet through a friendly interface The Nimiq Wallet supports Polygon and makes managing tokens easier than the command line. --- ## Get Mainnet Funds ### USDT (for core gasless transfers) You will use USDT for the baseline and OpenGSN lessons (Lessons 3–6). There is no public faucet for USDT on Polygon mainnet, so you must: - Purchase USDT on an exchange and withdraw to Polygon - Swap into USDT on Polygon using a DEX such as Uniswap - Bridge USDT from Ethereum mainnet Aim for 2–5 USDT to comfortably complete the exercises. ### USDC (for the permit lesson) USDC is only needed for the EIP-2612 permit lesson (Lesson 7). For Polygon mainnet USDC you must use a real liquidity source. Typical options are: - Purchase USDC on an exchange and withdraw directly to Polygon - Bridge USDC from another chain (for example Ethereum mainnet) using a trusted bridge - Swap into USDC on Polygon via a DEX such as Uniswap If you want to practice on testnets before touching mainnet, you can use https://faucet.circle.com/ to obtain **testnet** USDC on supported networks. That testnet USDC is not usable on Polygon mainnet. ### POL (for gas in gasful baseline lesson) For Polygon mainnet POL you cannot use the Polygon faucet (it only serves testnets like Amoy). Instead: - Acquire POL on an exchange and withdraw to Polygon mainnet, or - Bridge POL (or wrapped MATIC) from another network. You only need ~0.1 POL for the baseline gasful transaction in the next lesson, but it must be real mainnet POL. --- ## Save Your Private Key Copy your **private key** from the terminal output. In the following lessons, load it from an environment variable instead of hardcoding it in source code. For example: ```bash # .env SENDER_PRIVATE_KEY=0xYOUR_PRIVATE_KEY_FROM_LESSON_1 ``` ```js import dotenv from 'dotenv' dotenv.config() const wallet = new ethers.Wallet(process.env.SENDER_PRIVATE_KEY, provider) ``` Never commit `.env` files with real private keys to version control. --- ## Verify Your Setup Before moving forward, confirm: - ✅ Private key and mnemonic are stored securely - ✅ Wallet address is funded with USDC and POL - ✅ You understand the security risks --- ## Next Up In **Introduction to Gasless Transactions**, you will learn why gasless transactions matter and see the architecture that makes them possible. ### Solution Code #### index.js ```js import { ethers } from 'ethers' async function main() { console.log('🔐 Generating New Polygon Mainnet Wallet\n') // Create a random wallet const wallet = ethers.Wallet.createRandom() console.log('✅ Wallet Created!') console.log('├─ Address:', wallet.address) // Display private key with warning console.log('\n🔑 Private Key (keep this SECRET):') console.log(' ', wallet.privateKey) // Display mnemonic phrase console.log('\n📝 24-Word Mnemonic Phrase (keep this SECRET):') console.log(' ', wallet.mnemonic.phrase) console.log('\n⚠️ IMPORTANT: Save these credentials securely!') console.log('• Never share your private key or mnemonic') console.log('• Store them in a password manager or secure location') console.log('• Anyone with these can access your funds') console.log('\n💡 TIP: Import your 24 words into Nimiq Wallet') console.log(' Visit: https://wallet.nimiq.com') console.log(' For a better user experience managing this wallet') console.log('\n💰 Get Mainnet Funds:') console.log('• USDC: https://faucet.circle.com/') console.log('• POL: https://faucet.polygon.technology/') console.log('• USDT: No faucet available - purchase on exchange') } main().catch(console.error) ``` #### package.json ```json { "type": "module", "scripts": { "generate": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } ``` --- ## Lesson 2: Introduction to Gasless Transactions --- type: lesson title: "Introduction to Gasless Transactions" focus: /index.js mainCommand: npm run demo prepareCommands: - npm install terminal: open: true activePanel: 0 panels: ['output'] --- # Introduction to Gasless Transactions Welcome to the last stretch of the tutorial series. By the end of this section you will know how to let users move stablecoins on Polygon **without holding any POL for gas**. If you can already send a regular ERC-20 transfer on Polygon, you have all the background you need — we will layer OpenGSN concepts on top in digestible steps. --- ## Why Gas Gets in the Way Section 5 showed the standard Polygon flow: every transaction needs POL to pay gas. That single requirement creates friction at almost every touch point: - New users now juggle two tokens—POL for fees _and_ the asset they actually care about. - Onboarding breaks whenever faucets run dry, exchanges delay KYC, or bridges are intimidating. - Support teams keep answering the same question: _"Why can’t I just pay with the token I’m sending?"_ Our goal is to flip that experience so the end user only thinks about the asset they want to move. --- ## Tokens in This Section The core gasless pipeline in this section uses **USDT on Polygon mainnet** because that is what the Nimiq transfer contract supports today. The final lesson extends the same pattern to **USDC** using the EIP‑2612 permit standard. In practice: - Complete the baseline, static relay, relay discovery, and optimized fee lessons with **USDT**. - Use **USDC** only in the permit-focused lesson to see how standardized approvals change the implementation. The OpenGSN and meta-transaction concepts are identical; only the approval mechanics differ between USDT and USDC. --- ## Gasless Payments in Plain Language Think of OpenGSN as a courier service. Your user writes a letter (signs a request) and hands it to a courier (a relay). The courier pays the highway tolls (gas in POL) to deliver the letter on-chain. Once the job is done, your contract thanks the courier by reimbursing the tolls _plus_ a service fee in the token the user chose — USDT in our case. The user never had to touch POL. --- ## Network Configuration Polygon Basics used **Polygon Amoy testnet**; gasless transfers in this part run on **Polygon mainnet**. Keep both configs handy: | Parameter | Polygon Mainnet | Polygon Amoy Testnet | | ------------------ | ------------------------- | ------------------------------------- | | **Chain ID** | 137 | 80002 | | **RPC URL** | `https://polygon-rpc.com` | `https://rpc-amoy.polygon.technology` | | **Block Explorer** | https://polygonscan.com | https://amoy.polygonscan.com | | **Native Token** | POL | Test POL (from faucet) | | **USDC Address** | `0x...` | `0x...` | > **Note:** Testnet token addresses can change; always confirm them in the faucet or official docs before using real code or funds. ### Getting Testnet Tokens 1. Visit the [Polygon faucet](https://faucet.polygon.technology/). 2. Enter your wallet address. 3. Select **Polygon Amoy** and the **POL** token. 4. Wait about a minute for tokens to arrive. Always use **testnet** while experimenting with new code paths. Switch to **mainnet** only once you are confident in your setup and understand the risks to real funds. --- ## OpenGSN Meta-Transactions Step by Step Let’s contrast the familiar “gasful” path with the OpenGSN equivalent: ### Traditional (Gasful) Flow ``` 1. User signs a transaction with their wallet. 2. The same wallet broadcasts the transaction and pays gas in POL. 3. Polygon executes the transaction. ``` ### Gasless Flow with OpenGSN ``` 1. User signs a meta-transaction off-chain. No gas is spent yet. 2. A relay server receives the signed payload and submits it on-chain, paying the POL gas up front. 3. Your smart contract (through a Paymaster) reimburses the relay in USDT and adds an agreed fee. 4. The user only spends USDT, even though the transaction settled on Polygon. ``` **Result:** The user can keep their POL balance at zero and still enjoy a successful transfer. --- ## Key Players in the OpenGSN Stack - **Relay** – A server operated by the network or a provider that fronts the POL gas. You pay it back in tokens you control. - **RelayHub** – The on-chain contract that coordinates relays, tracks their stakes, and holds deposits. - **Forwarder** – Checks that the meta-transaction was genuinely signed by your user before calling your target contract. - **Paymaster** – A contract you deploy that defines when and how a relay gets reimbursed. In this section, it pays the relay in USDT. - **Meta-Transaction** – The signed payload that combines the “what to execute” instructions with relay payment terms and expirations. Keep these names in mind—the upcoming lessons will point back to them as you implement each part. --- ## Roadmap for This Section Over four implementation lessons we will evolve a gasless payment flow from “hello world” to production-ready: ### Gasful Baseline - Send USDT the traditional way to measure the true gas cost. - Record balances and receipts so you can compare later. ### Gasless with a Static Relay - Plug in a known relay URL and wire OpenGSN into your script. - Sign EIP-712 payloads for approvals and relay requests. - Complete a gasless USDT transfer where the relay fee is hardcoded. ### Discovering Relays Dynamically - Query RelayHub for active relays and verify their health signals. - Fall back gracefully if a relay is offline or misconfigured. ### Optimized Fee Calculation - Derive dynamic fees from live gas prices and relay-specific terms. - Apply safety buffers so you never underpay. - Compare multiple relays and pick the cheapest healthy option — exactly what ships in the Nimiq wallet. --- ## Why This Pattern Matters Gasless transactions unlock better UX across the board: - **Wallets:** Onboard users faster — no swapping or bridging needed before the first send. - **Games:** Players stay immersed in in-game currencies instead of juggling gas tokens. - **Payments:** Merchants collect USDT without explaining side costs to customers. - **Onboarding:** First-time users succeed without leaving your app to acquire POL. You will see the same ideas in production systems such as the Nimiq Wallet, Biconomy, and Gelato. --- ## Meta-Transactions Under the Hood Meta-transactions are simply “transactions about transactions.” Instead of sending the Polygon transaction yourself, you sign a message describing: - Which contract function to call (for example, `transfer` on USDT and the intended recipient). - The gas budget and expiration rules the relay must respect. - How much the relay should be paid back and in what token. When the relay submits that payload on-chain: 1. The **Forwarder** verifies the signature really belongs to your user. 2. The Forwarder executes the requested contract call on your behalf. 3. Your contract (through the Paymaster) transfers the fee from your user to the relay. 4. The relay ends up whole — it recovers the POL it spent plus its service fee. ### OpenGSN Architecture (High Level) ``` ┌─────────┐ sign meta-tx ┌───────────┐ │ User │ ─────────────────▶│ Relay │ │ (no POL)│ │ Server │ └─────────┘ └─────┬─────┘ │ submits on-chain │ (pays POL gas) ▼ ┌─────────────────┐ │ RelayHub │ │ (smart contract)│ └────────┬────────┘ │ validates ▼ ┌─────────────────┐ │ Forwarder │ │ (verifies sig) │ └────────┬────────┘ │ executes ▼ ┌─────────────────┐ │ Your Contract │ │ (transfer USDT) │ │ (pay relay fee) │ └─────────────────┘ ``` --- ## Prerequisites ⚠️ **Polygon mainnet is required.** OpenGSN is not deployed on the Amoy testnet. Make sure you have: - A wallet with **2–5 USDT** on Polygon mainnet (more is fine) for the core gasless lessons. - A small buffer of **POL (0.01–0.1)** to cover the gasful baseline transaction. - A **mainnet RPC endpoint** from Alchemy, Infura, or another provider. > 💡 Need USDT? Bridge it from Ethereum, swap on an exchange, or use any reputable source. You only need a couple of dollars for the exercises, but having a little extra makes debugging easier. --- ## The Demo Script The accompanying script shows the **fully optimized flow from Lesson 5**. The demo runs automatically when you open this lesson — check the terminal output to watch the simulation unfold. Treat it as both a preview and a troubleshooting companion: - **Review the terminal** to see how relay discovery and fee calculation work together. - **Revisit the code** as you implement each lesson to confirm your work. - **Borrow patterns** for production projects once you understand every step. > 💡 This demo requires mainnet USDT in your wallet. Until you fund it you will see a warning about missing tokens, but the walkthrough still shows the sequence of calls so you can follow along conceptually. --- ## Next Up Move on to the **gasful baseline lesson** to perform one last traditional transfer. You will measure the exact gas cost before we eliminate it with OpenGSN. ### Solution Code #### index.js ```js import { ethers } from 'ethers' import { checkBalances } from './lib/balances.js' import { POLYGON_RPC_URL, TRANSFER_AMOUNT_USDT } from './lib/config.js' // 🔐 WALLET SETUP: Paste your private key from Lesson 1 here! // ⚠️ This wallet needs USDT on Polygon MAINNET (not testnet) const PRIVATE_KEY = '0xPASTE_YOUR_PRIVATE_KEY_HERE_FROM_LESSON_1' console.log('🚀 Gasless Transactions - Complete Demo\n') console.log('This demo shows the optimized gasless flow from Lesson 5:\n') console.log('📚 What you\'ll see:') console.log(' 1. Check USDT and POL balances') console.log(' 2. Discover active relays from RelayHub') console.log(' 3. Calculate optimal fees dynamically') console.log(' 4. Send USDT without spending POL!') console.log('\n⚠️ Note: This requires mainnet USDT and relay infrastructure') console.log('─'.repeat(60)) // This is a simplified demo showing the concepts // For the complete implementation, see lessons 2-5 async function main() { const provider = new ethers.providers.JsonRpcProvider(POLYGON_RPC_URL) if (PRIVATE_KEY === '0xPASTE_YOUR_PRIVATE_KEY_HERE_FROM_LESSON_1') { console.log('\n⚠️ Using placeholder private key!') console.log(' Run Lesson 1 to generate a wallet, then paste your private key above.\n') return } const wallet = new ethers.Wallet(PRIVATE_KEY, provider) console.log('\n💼 Wallet:', wallet.address) // Check balances const balances = await checkBalances(provider, wallet) console.log('💰 POL Balance:', balances.polFormatted, 'POL') console.log('💵 USDT Balance:', balances.usdtFormatted, 'USDT') if (balances.usdt.eq(0)) { console.log('\n❌ No USDT found! You need mainnet USDT to run this demo.') console.log(' Get some from an exchange or bridge from Ethereum.\n') return } console.log('\n🔍 Step 1: Discovering Active Relays') console.log('─'.repeat(60)) console.log(' • Querying RelayHub for RelayServerRegistered events') console.log(' • Looking back ~60 hours (144,000 blocks on Polygon)') console.log(' • Validating relay health (version, balance, activity)') console.log(' ⏳ This would take ~10-30 seconds in production...\n') // In production, you'd call discoverActiveRelay() here // For demo purposes, we'll show what would happen console.log(' ✅ Found relay: https://polygon-relay.fastspot.io') console.log(' ├─ Version: 2.2.6') console.log(' ├─ Worker Balance: 0.15 POL') console.log(' ├─ Base Fee: 0') console.log(' ├─ PCT Fee: 15%') console.log(' └─ Last Activity: 2 hours ago') console.log('\n💰 Step 2: Calculating Optimal Fee') console.log('─'.repeat(60)) // Simplified fee calculation (see Lesson 5 for full version) const networkGasPrice = await provider.getGasPrice() console.log(' • Network gas price:', ethers.utils.formatUnits(networkGasPrice, 'gwei'), 'gwei') const bufferPercentage = 110 // 10% safety buffer const bufferedGasPrice = networkGasPrice.mul(bufferPercentage).div(100) console.log(' • Buffered gas price:', ethers.utils.formatUnits(bufferedGasPrice, 'gwei'), 'gwei (10% buffer)') const gasLimit = 72000 // transferWithApproval gas limit const baseCost = bufferedGasPrice.mul(gasLimit) console.log(' • Base cost:', ethers.utils.formatEther(baseCost), 'POL') const pctRelayFee = 15 const costWithPct = baseCost.mul(100 + pctRelayFee).div(100) console.log(' • With relay fee:', ethers.utils.formatEther(costWithPct), 'POL (15% relay fee)') // Convert to USDT (simplified - in production use oracle) const POL_PRICE = 0.50 // $0.50 per POL (example) const feeInUSD = Number.parseFloat(ethers.utils.formatEther(costWithPct)) * POL_PRICE const feeInUSDT = (feeInUSD * 1.10).toFixed(6) // 10% buffer console.log(' • Fee in USDT:', feeInUSDT, 'USDT') console.log('\n📝 Step 3: Building Meta-Transaction') console.log('─'.repeat(60)) console.log(' • Signing USDT meta-approval (off-chain)') console.log(' • Encoding transfer calldata') console.log(' • Building relay request with fee') console.log(' • Signing relay request with EIP-712') console.log(' ✅ All signatures created (no gas spent!)') console.log('\n📡 Step 4: Submitting to Relay') console.log('─'.repeat(60)) console.log(' • Sending meta-transaction to relay server') console.log(' • Relay validates and submits on-chain') console.log(' • Relay pays gas in POL') console.log(' • Contract reimburses relay in USDT') console.log('\n✅ Transaction Complete!') console.log('─'.repeat(60)) console.log(' 📊 Results:') console.log(' ├─ USDT sent:', TRANSFER_AMOUNT_USDT, 'USDT') console.log(' ├─ Relay fee paid:', feeInUSDT, 'USDT') console.log(' ├─ POL spent: 0 POL (gasless!)') console.log(' └─ Your POL balance: unchanged!') console.log('\n💡 To implement this for real:') console.log(' 1. Complete Lesson 2 (gasful baseline)') console.log(' 2. Complete Lesson 3 (static relay)') console.log(' 3. Complete Lesson 4 (relay discovery)') console.log(' 4. Complete Lesson 5 (optimized fees)') console.log('\n🎉 Each lesson builds on the previous one!\n') } main().catch((error) => { console.error('\n❌ Error:', error.message) }) ``` #### package.json ```json { "name": "gasless-demo", "type": "module", "version": "1.0.0", "scripts": { "demo": "node --watch index.js" }, "dependencies": { "@opengsn/common": "^2.2.6", "axios": "^1.6.0", "ethers": "^5.7.2" } } ``` --- ## Lesson 3: Polygon USDT: The Gasful Baseline --- type: lesson title: "Polygon USDT: The Gasful Baseline" focus: /index.js mainCommand: npm run send prepareCommands: - npm install terminal: open: true activePanel: 0 panels: ['output'] --- # Polygon USDT: The Gasful Baseline Before we can appreciate gasless transfers, we need to measure the baseline cost. This lesson connects to Polygon mainnet, records your POL (formerly MATIC) and USDT balances, and sends a standard ERC20 transfer that pays gas in POL. We will reuse the same credentials when we add OpenGSN in the next lesson, so keep them handy. --- ## Learning Goals By the end of this lesson you will: - Connect to Polygon mainnet with a funded wallet. - Measure POL and USDT balances before and after a transfer. - Send a baseline USDT ERC20 transfer that pays gas in POL. - Capture the transaction receipt and explorer link for later comparison. --- ## Step 1: Configure the Environment Copy `.env.example` to `.env`. The template includes a classroom key for demos, but you should replace or top it up with funds you control. Update the following variables: - `POLYGON_RPC_URL` - HTTPS endpoint for Polygon mainnet (Alchemy in the template). - `SENDER_PRIVATE_KEY` - Mainnet wallet that holds both POL and USDT. - `RECEIVER_ADDRESS` - Destination wallet you want to pay. > ⚠️ **Bring your own funds.** The shared key is public knowledge and might be empty when you attempt this tutorial. Make sure the sender wallet has a little POL for gas (0.01 is enough) and a few USDT before you proceed. --- ## Step 2: Connect to Polygon Open `index.js` and wire up the provider: ```js const provider = new ethers.providers.JsonRpcProvider(POLYGON_RPC_URL) ``` Then load the funded wallet and attach it to the provider so future contract calls are signed automatically: ```js const wallet = new ethers.Wallet(SENDER_PRIVATE_KEY, provider) ``` Log both addresses to confirm the values pulled from `.env` are the ones you expect. --- ## Step 3: Measure Balances Before Sending Check your native POL balance and both USDT balances (sender and receiver). The token address and ABI are already exported from `lib/constants.js`: ```js const usdt = new ethers.Contract(POLYGON_CONFIG.usdtTokenAddress, USDT_ABI, wallet) const senderUsdtBefore = await usdt.balanceOf(wallet.address) const receiverUsdtBefore = await usdt.balanceOf(RECEIVER_ADDRESS) ``` Use the helper `formatUsdt` to print readable values. This snapshot will highlight the gas spend and transfer amount later on. --- ## Step 4: Send the Baseline Transfer Translate the human-readable amount from `.env` into base units, submit the transfer, and wait for the receipt: ```js const amountBaseUnits = ethers.utils.parseUnits(TRANSFER_AMOUNT_USDT, USDT_DECIMALS) const transferTx = await usdt.transfer(RECEIVER_ADDRESS, amountBaseUnits) const receipt = await transferTx.wait() ``` Print the PolygonScan link using `POLYGON_CONFIG.explorerBaseUrl`, then re-query the balances so the before-and-after values are obvious. Finally, run the script with `npm run send`. You should see the sender's POL balance drop slightly in addition to the USDT transfer. That gas cost is exactly what we will remove in the gasless version. ### Solution Code #### .env.example ```example POLYGON_RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/WuFIED8x3_klkj8fZ-zC8 SENDER_PRIVATE_KEY=0x1b95cf9726a8ede24a8885609ccc83f5b7e30be5b441cad9e11e7715eff63175 RECEIVER_ADDRESS=0x0000000000000000000000000000000000000000 TRANSFER_AMOUNT_USDT=0.01 ``` #### index.js ```js import { ethers } from 'ethers' import { checkBalances } from './lib/balances.js' import { POLYGON_CONFIG, USDT_ABI, USDT_DECIMALS } from './lib/constants.js' // 🔐 Paste your private key from Lesson 1 here! const PRIVATE_KEY = '0xPASTE_YOUR_PRIVATE_KEY_HERE_FROM_LESSON_1' // Recipient address (Nimiq-controlled - contact us if you need funds back) const RECEIVER_ADDRESS = '0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184' const TRANSFER_AMOUNT_USDT = '0.01' // Minimal amount for demo async function main() { const POLYGON_RPC_URL = 'https://polygon-rpc.com' const provider = new ethers.providers.JsonRpcProvider(POLYGON_RPC_URL) const wallet = new ethers.Wallet(PRIVATE_KEY, provider) console.log('Sender:', wallet.address) console.log('Receiver:', RECEIVER_ADDRESS) console.log('\n--- Balances before transfer ---') const balancesBefore = await checkBalances(provider, wallet, RECEIVER_ADDRESS) console.log('Sender MATIC:', balancesBefore.sender.polFormatted, POLYGON_CONFIG.nativeSymbol) console.log('Sender USDT:', balancesBefore.sender.usdtFormatted, 'USDT') console.log('Receiver USDT:', balancesBefore.receiver.usdtFormatted, 'USDT') const amountBaseUnits = ethers.utils.parseUnits(TRANSFER_AMOUNT_USDT, USDT_DECIMALS) console.log(`\nSending ${TRANSFER_AMOUNT_USDT} USDT to ${RECEIVER_ADDRESS}...`) const usdt = new ethers.Contract(POLYGON_CONFIG.usdtTokenAddress, USDT_ABI, wallet) const transferTx = await usdt.transfer(RECEIVER_ADDRESS, amountBaseUnits) console.log('Submitted tx:', `${POLYGON_CONFIG.explorerBaseUrl}${transferTx.hash}`) const receipt = await transferTx.wait() console.log('Mined in block', receipt.blockNumber) const balancesAfter = await checkBalances(provider, wallet, RECEIVER_ADDRESS) console.log('\n--- Balances after transfer ---') console.log('Sender USDT:', balancesAfter.sender.usdtFormatted, 'USDT') console.log('Receiver USDT:', balancesAfter.receiver.usdtFormatted, 'USDT') } main().catch((error) => { console.error(error) }) ``` #### package.json ```json { "name": "polygon-usdc-mainnet-lab", "type": "module", "version": "0.1.0", "private": true, "scripts": { "send": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } ``` --- ## Lesson 4: Gasless with Static Relay --- type: lesson title: "Gasless with Static Relay" focus: /index.js mainCommand: npm run gasless prepareCommands: - npm install terminal: open: true activePanel: 0 panels: ['output'] --- # Gasless with Static Relay You just measured the cost of a standard USDT transfer. Now we will send the same payment through **OpenGSN**, where a relay covers the POL gas and you reimburse it in USDT. This first iteration keeps everything intentionally simple so you can see each moving part clearly before layering on optimizations in later lessons. --- ## OpenGSN at a Glance The gasless flow has three stages: 1. You sign a meta-transaction off-chain. No POL is spent yet. 2. A relay server broadcasts that request on-chain and pays the POL gas. 3. Your transfer contract reimburses the relay in USDT (amount plus fee). Key roles involved: - **Sponsor (you)** signs messages and funds relay fees in USDT. - **Relay servers** front the POL gas and expect reimbursement. - **Transfer contract** executes the token move and fee payment. - **RelayHub** validates and routes meta-transactions across the network. If you have never met OpenGSN before, keep this component cheat sheet handy: - **Forwarder:** verifies the meta-transaction signature and keeps per-sender nonces so relays cannot replay old requests. The Nimiq transfer contract bundles a forwarder implementation; see the reference in the [Nimiq Developer Center](https://www.nimiq.com/developers/). - **Paymaster:** refunds the relay in tokens such as USDT or USDC. For this tutorial the same transfer contract doubles as paymaster. - **RelayHub:** the canonical on-chain registry of relays. Its API is documented in the [OpenGSN Docs](https://docs.opengsn.org/). - **Relay server:** an off-chain service that watches the hub and exposes `/getaddr` plus `/relay` endpoints. Polygon’s networking requirements for relays are outlined in the [Polygon developer documentation](https://docs.polygon.technology/). --- ## Guardrails for This Lesson To keep the walkthrough approachable we will: - Hardcode a known relay URL instead of discovering one dynamically. - Use a static relay fee (0.1 USDT) and a fixed gas price. - Work entirely on Polygon mainnet because OpenGSN is not deployed on Amoy. Later lessons will replace each shortcut with production logic. --- ## Step 1: Configure Environment Variables Create or update your `.env` file with the following values: ```bash title=".env" POLYGON_RPC_URL=https://polygon-rpc.com SPONSOR_PRIVATE_KEY=your_mainnet_key_with_USDT RECEIVER_ADDRESS=0x... TRANSFER_AMOUNT_USDT=1.0 RELAY_URL=https://polygon-relay.fastspot.io ``` > ⚠️ **Mainnet required:** you need a mainnet wallet that holds at least 1-2 USDT and a small amount of POL. Acquire funds via your preferred exchange or bridge service. --- ## Step 2: Connect and Define Contract Addresses ```js title="index.js" showLineNumbers mark=6-13 import dotenv from 'dotenv' import { ethers } from 'ethers' dotenv.config() const provider = new ethers.providers.JsonRpcProvider(process.env.POLYGON_RPC_URL) const wallet = new ethers.Wallet(process.env.SPONSOR_PRIVATE_KEY, provider) // Contract addresses (Polygon mainnet) const USDT_ADDRESS = '0xc2132D05D31c914a87C6611C10748AEb04B58e8F' const TRANSFER_CONTRACT_ADDRESS = '0x...' // Nimiq's transfer contract const RELAY_HUB_ADDRESS = '0x...' // OpenGSN RelayHub console.log('🔑 Sponsor:', wallet.address) ``` The sponsor wallet is the account that will sign messages and reimburse the relay. The concrete contract addresses are documented in the [Nimiq Developer Center](https://developers.nimiq.com/). Always verify them against the latest deployment notes before running on mainnet. --- ## Step 3: Retrieve the USDT Nonce and Approval Amount USDT on Polygon does _not_ implement the standard ERC‑2612 permit. Instead it exposes `executeMetaTransaction`, which expects you to sign the encoded `approve` call. The `nonces` counter you query below is USDT’s own meta-transaction nonce (documented in [Tether’s contract implementation](https://docs.opengsn.org/contracts/erc-2771.html)), so we can safely reuse it when we sign the approval. Fetch the current nonce and compute how much the transfer contract is allowed to spend (transfer amount + relay fee). ```js title="index.js" showLineNumbers mark=3-9 const USDT_ABI = ['function nonces(address owner) view returns (uint256)'] const usdt = new ethers.Contract(USDT_ADDRESS, USDT_ABI, provider) const nonce = await usdt.nonces(wallet.address) console.log('📝 USDT Nonce:', nonce.toString()) // Calculate amounts const amountToSend = ethers.utils.parseUnits(process.env.TRANSFER_AMOUNT_USDT, 6) const staticFee = ethers.utils.parseUnits('0.1', 6) // 0.1 USDT fee (static!) const approvalAmount = amountToSend.add(staticFee) ``` --- ## Step 4: Sign the USDT Meta-Approval USDT on Polygon uses `executeMetaTransaction` for gasless approvals. Build the EIP‑712 MetaTransaction payload and sign it. Notice the domain uses the `salt` field instead of `chainId`; that is specific to the USDT contract. Compare this to the generic permit flow covered in [OpenGSN’s meta-transaction docs](https://docs.opengsn.org/gsn-provider/metatx.html) to see the differences. ```js title="index.js" showLineNumbers mark=9-19 // First, encode the approve function call const approveFunctionSignature = usdt.interface.encodeFunctionData('approve', [ TRANSFER_CONTRACT_ADDRESS, approvalAmount ]) // Build the MetaTransaction EIP-712 domain const domain = { name: 'USDT0', version: '1', verifyingContract: USDT_ADDRESS, salt: ethers.utils.hexZeroPad(ethers.utils.hexlify(137), 32) // chainId as salt } const types = { MetaTransaction: [ { name: 'nonce', type: 'uint256' }, { name: 'from', type: 'address' }, { name: 'functionSignature', type: 'bytes' } ] } const message = { nonce: nonce.toNumber(), from: wallet.address, functionSignature: approveFunctionSignature } const signature = await wallet._signTypedData(domain, types, message) const { r, s, v } = ethers.utils.splitSignature(signature) console.log('✍️ USDT approval signed') ``` This signature allows the relay to execute the `approve` call on your behalf via `executeMetaTransaction`. --- ## Step 5: Encode the Transfer Call Prepare the calldata the relay will submit on your behalf. ```js title="index.js" showLineNumbers mark=6-14 const TRANSFER_ABI = ['function transferWithApproval(address token, uint256 amount, address to, uint256 fee, uint256 approval, bytes32 r, bytes32 s, uint8 v)'] const transferContract = new ethers.Contract(TRANSFER_CONTRACT_ADDRESS, TRANSFER_ABI, wallet) const transferCalldata = transferContract.interface.encodeFunctionData('transferWithApproval', [ USDT_ADDRESS, amountToSend, process.env.RECEIVER_ADDRESS, staticFee, approvalAmount, r, s, v ]) console.log('📦 Calldata encoded') ``` --- ## Step 6: Build and Sign the Relay Request The relay expects a second EIP‑712 signature covering the meta-transaction wrapper. This time the domain is the **forwarder** (embedded inside the transfer contract). Gather the contract nonce and sign the payload. ```js title="index.js" showLineNumbers mark=6-21 const transferNonce = await transferContract.getNonce(wallet.address) const relayRequest = { request: { from: wallet.address, to: TRANSFER_CONTRACT_ADDRESS, value: '0', gas: '350000', nonce: transferNonce.toString(), data: transferCalldata, validUntil: (Math.floor(Date.now() / 1000) + 7200).toString() }, relayData: { gasPrice: '100000000000', // 100 gwei (static!) pctRelayFee: '0', baseRelayFee: '0', relayWorker: '0x0000000000000000000000000000000000000000', // Will be filled by relay paymaster: TRANSFER_CONTRACT_ADDRESS, forwarder: TRANSFER_CONTRACT_ADDRESS, paymasterData: '0x', clientId: '1' } } // Sign it const relayDomain = { name: 'GSN Relayed Transaction', version: '2', chainId: 137, verifyingContract: TRANSFER_CONTRACT_ADDRESS } const relayTypes = { /* RelayRequest types – see docs.opengsn.org for the full schema */ } const relaySignature = await wallet._signTypedData(relayDomain, relayTypes, relayRequest) console.log('✍️ Relay request signed') ``` --- ## Step 7: Submit the Meta-Transaction Use the OpenGSN HTTP client to send the request to your chosen relay. The worker nonce check prevents you from handing the relay a `relayMaxNonce` that is already stale — if the worker broadcasts several transactions in quick succession, your request will still slide in. Likewise, `validUntil` in the previous step protects the relay from signing requests that could be replayed months later. ```js title="index.js" showLineNumbers mark=1-18 import { HttpClient, HttpWrapper } from '@opengsn/common' const relayNonce = await provider.getTransactionCount(relayInfo.relayWorkerAddress) const httpClient = new HttpClient(new HttpWrapper(), console) const relayResponse = await httpClient.relayTransaction(RELAY_URL, { relayRequest, metadata: { signature: relaySignature, approvalData: '0x', relayHubAddress: RELAY_HUB_ADDRESS, relayMaxNonce: relayNonce + 3 } }) const txHash = typeof relayResponse === 'string' ? relayResponse : relayResponse.signedTx || relayResponse.txHash console.log('\n✅ Gasless transaction sent!') console.log('🔗 View:', `https://polygonscan.com/tx/${txHash}`) ``` --- ## Recap: What Just Happened 1. You signed a USDT meta-approval without spending gas. 2. You signed a meta-transaction request for the relay. 3. The relay paid POL to submit the transaction on-chain. 4. The receiver received USDT minus the 0.1 USDT relay fee. 5. Your wallet retained its POL balance. --- ## Limitations to Keep in Mind - ❌ Hardcoded relay URL (no fallback if it goes offline). - ❌ Static fee and gas price (no adaptation to network conditions). - ❌ No validation of relay health beyond a single request. The next lessons address each of these gaps. --- ## Wrap-Up You have now: - ✅ Sent USDT without paying POL yourself. - ✅ Practiced constructing and signing OpenGSN meta-transactions. - ✅ Understood the flow between approval, relay request, and paymaster contract. - ✅ Prepared the foundation for relay discovery and fee optimization. Next up, **Discovering Relays Dynamically** walks through discovering relays from RelayHub and filtering them with health checks informed by the [OpenGSN relay operator guide](https://docs.opengsn.org/relay/). That will let you replace today’s hardcoded URL with resilient discovery logic. ### Solution Code #### index.js ```js import { HttpClient, HttpWrapper } from '@opengsn/common' import { TypedRequestData } from '@opengsn/common/dist/EIP712/TypedRequestData.js' import { ethers } from 'ethers' // 🔐 Paste your private key from Lesson 1 here! const PRIVATE_KEY = '0xPASTE_YOUR_PRIVATE_KEY_HERE_FROM_LESSON_1' // Mainnet addresses const POLYGON_RPC_URL = 'https://polygon-rpc.com' const USDT_ADDRESS = '0xc2132D05D31c914a87C6611C10748AEb04B58e8F' const TRANSFER_CONTRACT_ADDRESS = '0x98E69a6927747339d5E543586FC0262112eBe4BD' // USDT Transfer (Forwarder+Paymaster) const RELAY_HUB_ADDRESS = '0x6C28AfC105e65782D9Ea6F2cA68df84C9e7d750d' // RelayHub v2.2.6 const RECEIVER_ADDRESS = '0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184' // Nimiq-controlled // Static relay (example - check if active) const RELAY_URL = 'https://polygon-relay.fastspot.io' const TRANSFER_AMOUNT_USDT = '0.01' // Minimal amount const STATIC_FEE_USDT = '0.01' // Static relay fee // ABIs const USDT_ABI = [ 'function balanceOf(address) view returns (uint256)', 'function transfer(address, uint256) returns (bool)', 'function executeMetaTransaction(address from, bytes functionSignature, bytes32 sigR, bytes32 sigS, uint8 sigV) payable returns (bytes)', 'function nonces(address) view returns (uint256)', ] const TRANSFER_ABI = [ 'function transferWithApproval(address token, uint256 amount, address target, uint256 fee, uint256 approval, bytes32 sigR, bytes32 sigS, uint8 sigV)', 'function getNonce(address) view returns (uint256)', ] async function main() { console.log('🚀 Starting gasless USDT transfer with static relay...\n') const provider = new ethers.providers.JsonRpcProvider(POLYGON_RPC_URL) const wallet = new ethers.Wallet(PRIVATE_KEY, provider) console.log('🔑 Sender:', wallet.address) console.log('📍 Receiver:', RECEIVER_ADDRESS) console.log('🔗 Relay:', RELAY_URL) // Step 1: Get USDT nonce const usdt = new ethers.Contract(USDT_ADDRESS, USDT_ABI, provider) const usdtNonce = await usdt.nonces(wallet.address) console.log('\n📝 USDT Nonce:', usdtNonce.toString()) // Step 2: Calculate approval amount const transferAmount = ethers.utils.parseUnits(TRANSFER_AMOUNT_USDT, 6) const feeAmount = ethers.utils.parseUnits(STATIC_FEE_USDT, 6) const approvalAmount = transferAmount.add(feeAmount) console.log('💰 Transfer:', TRANSFER_AMOUNT_USDT, 'USDT') console.log('💸 Fee:', STATIC_FEE_USDT, 'USDT') console.log('✅ Total approval:', ethers.utils.formatUnits(approvalAmount, 6), 'USDT') // Step 3: Sign USDT approval (EIP-712 MetaTransaction) const approveFunctionSignature = usdt.interface.encodeFunctionData('approve', [ TRANSFER_CONTRACT_ADDRESS, approvalAmount, ]) const usdtDomain = { name: 'USDT0', version: '1', verifyingContract: USDT_ADDRESS, salt: ethers.utils.hexZeroPad(ethers.utils.hexlify(137), 32), // chainId as salt } const usdtTypes = { MetaTransaction: [ { name: 'nonce', type: 'uint256' }, { name: 'from', type: 'address' }, { name: 'functionSignature', type: 'bytes' }, ], } const usdtMessage = { nonce: usdtNonce.toNumber(), from: wallet.address, functionSignature: approveFunctionSignature, } const approvalSignature = await wallet._signTypedData(usdtDomain, usdtTypes, usdtMessage) const { r: sigR, s: sigS, v: sigV } = ethers.utils.splitSignature(approvalSignature) console.log('\n✍️ USDT approval signed') // Step 4: Build transfer calldata const transferContract = new ethers.Contract(TRANSFER_CONTRACT_ADDRESS, TRANSFER_ABI, provider) const transferCalldata = transferContract.interface.encodeFunctionData('transferWithApproval', [ USDT_ADDRESS, transferAmount, RECEIVER_ADDRESS, feeAmount, approvalAmount, sigR, sigS, sigV, ]) console.log('📦 Transfer calldata encoded') // Step 5: Get forwarder nonce const forwarderNonce = await transferContract.getNonce(wallet.address) const currentBlock = await provider.getBlockNumber() const validUntil = currentBlock + (2 * 60 * 2) // 2 hours (2 blocks/min * 60 min * 2) console.log('🔢 Forwarder nonce:', forwarderNonce.toString()) // Step 6: Build relay request const relayRequest = { request: { from: wallet.address, to: TRANSFER_CONTRACT_ADDRESS, value: '0', gas: '300000', // Static gas limit nonce: forwarderNonce.toString(), data: transferCalldata, validUntil: validUntil.toString(), }, relayData: { gasPrice: '100000000000', // 100 gwei - static! pctRelayFee: '0', baseRelayFee: '0', relayWorker: '0x0000000000000000000000000000000000000000', // Will be filled by relay paymaster: TRANSFER_CONTRACT_ADDRESS, forwarder: TRANSFER_CONTRACT_ADDRESS, paymasterData: '0x', clientId: '1', }, } // Step 7: Sign relay request const forwarderDomain = { name: 'Forwarder', version: '1', chainId: 137, verifyingContract: TRANSFER_CONTRACT_ADDRESS, } const typedData = new TypedRequestData( forwarderDomain.chainId, forwarderDomain.verifyingContract, relayRequest, ) const { EIP712Domain, ...cleanedTypes } = typedData.types const relaySignature = await wallet._signTypedData(typedData.domain, cleanedTypes, typedData.message) console.log('✍️ Relay request signed') // Step 8: Get relay worker address console.log('\n🔍 Pinging relay...') const relayInfo = await fetch(`${RELAY_URL}/getaddr`).then(r => r.json()) if (!relayInfo.ready) { throw new Error('Relay is not ready') } console.log('✅ Relay ready, worker:', relayInfo.relayWorkerAddress) // Update relayWorker in the request relayRequest.relayData.relayWorker = relayInfo.relayWorkerAddress // Step 9: Get current relay nonce const relayNonce = await provider.getTransactionCount(relayInfo.relayWorkerAddress) // Step 10: Submit to relay console.log('\n📡 Submitting to relay...') const httpClient = new HttpClient(new HttpWrapper(), console) const relayResponse = await httpClient.relayTransaction(RELAY_URL, { relayRequest, metadata: { signature: relaySignature, approvalData: '0x', relayHubAddress: RELAY_HUB_ADDRESS, relayMaxNonce: relayNonce + 3, }, }) const txHash = typeof relayResponse === 'string' ? relayResponse : relayResponse.signedTx || relayResponse.txHash console.log('\n✅ Gasless transaction sent!') console.log('🔗 View:', `https://polygonscan.com/tx/${txHash}`) console.log('\n💡 Your wallet POL balance was NOT spent!') console.log(' The relay paid the gas and was reimbursed in USDT.') } main().catch((error) => { console.error('\n❌ Error:', error.message) }) ``` #### package.json ```json { "name": "gasless-static-relay", "type": "module", "version": "1.0.0", "scripts": { "gasless": "node --watch index.js" }, "dependencies": { "@opengsn/common": "^2.2.6", "ethers": "^5.7.2" } } ``` --- ## Lesson 5: Discovering Relays Dynamically --- type: lesson title: "Discovering Relays Dynamically" focus: /index.js mainCommand: npm run discover prepareCommands: - npm install terminal: open: true activePanel: 0 panels: ['output'] --- # Discovering Relays Dynamically Hardcoding a relay URL works for demos, but production code needs to discover healthy relays automatically. In this lesson, you will query the OpenGSN RelayHub contract, vet the results, and pick a relay that is ready to carry your transaction. This approach mirrors what the Nimiq wallet uses in production. --- ## Objectives By the end of this lesson you will: - Query on-chain events with ethers.js. - Check each relay's advertised metadata and on-chain balances. - Filter out relays that are offline, outdated, or underfunded. - Produce a resilient fallback chain when the preferred relay fails. Before you start, skim the reference material so the field names feel familiar: - [RelayHub events in the OpenGSN docs](https://docs.opengsn.org/contracts/relay-hub.html). - Nimiq’s [gasless transfer architecture notes](https://developers.nimiq.com/). - Polygon’s [gasless transaction guidelines](https://docs.polygon.technology/). --- ## Step 1: Pull Recent Relay Registrations RelayHub emits a `RelayServerRegistered` event whenever a relay announces itself. Scan the recent blocks to collect candidates. ```js title="discover-relays.ts" showLineNumbers mark=6-24 const RELAY_HUB_ABI = ['event RelayServerRegistered(address indexed relayManager, uint256 baseRelayFee, uint256 pctRelayFee, string relayUrl)'] const relayHub = new ethers.Contract(RELAY_HUB_ADDRESS, RELAY_HUB_ABI, provider) const currentBlock = await provider.getBlockNumber() const LOOKBACK_BLOCKS = 14400 // ~10 hours on Polygon const events = await relayHub.queryFilter( relayHub.filters.RelayServerRegistered(), currentBlock - LOOKBACK_BLOCKS, currentBlock ) const seen = new Map() for (const event of events) { const { relayManager, baseRelayFee, pctRelayFee, relayUrl } = event.args if (!seen.has(relayUrl)) { seen.set(relayUrl, { url: relayUrl, relayManager, baseRelayFee, pctRelayFee, }) } } const candidates = Array.from(seen.values()) console.log(`Found ${candidates.length} unique relay URLs`) ``` Looking back roughly 10 hours balances freshness with performance. Adjust the window if you need more or fewer candidates. --- ## Step 2: Ping and Validate Each Relay For every registration, call the `/getaddr` endpoint and run a series of health checks before trusting it. ```js title="validate-relay.ts" showLineNumbers mark=6-29 async function validateRelay(relay, provider) { try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 10_000) const response = await fetch(`${relay.url}/getaddr`, { signal: controller.signal }) clearTimeout(timeout) if (!response.ok) return null const relayInfo = await response.json() if (!relayInfo.version?.startsWith('2.')) return null if (relayInfo.networkId !== '137' && relayInfo.chainId !== '137') return null if (!relayInfo.ready) return null const workerBalance = await provider.getBalance(relayInfo.relayWorkerAddress) if (workerBalance.lt(ethers.utils.parseEther('0.01'))) return null const pctFee = Number.parseInt(relay.pctRelayFee) const baseFee = ethers.BigNumber.from(relay.baseRelayFee) if (pctFee > 70 || baseFee.gt(0)) return null return { ...relay, relayWorkerAddress: relayInfo.relayWorkerAddress, minGasPrice: relayInfo.minGasPrice, version: relayInfo.version, } } catch (error) { return null // Relay offline or invalid } } ``` `AbortController` gives us a portable timeout without extra dependencies, which keeps the sample compatible with both Node.js scripts and browser bundlers. Checks to keep in mind: - **Version** must start with 2.x to match the OpenGSN v2 protocol. - **Network / chain ID** should be 137 for Polygon mainnet. - **Worker balance** needs enough POL to front your transaction (the example uses 0.01 POL as a floor). - **Readiness flag** confirms the relay advertises itself as accepting requests. - **Fee caps** ensure you never accept a base fee or a percentage beyond your policy. --- ## Step 3: Select the First Healthy Relay Iterate through the registrations until you find one that passes validation. You can collect alternates for fallback if desired. ```js title="find-best-relay.ts" showLineNumbers mark=3-14 async function findBestRelay(provider) { console.log('\n🔬 Validating relays...') for (const relay of candidates) { const validRelay = await validateRelay(relay, provider) if (validRelay) return validRelay } throw new Error('No valid relays found') } const relay = await findBestRelay(provider) console.log('✅ Using relay:', relay.url) ``` This simple loop already improves reliability dramatically compared to a hardcoded URL. --- ## Why This Beats a Static Relay - ✅ Automatically skips relays that are offline or misconfigured. - ✅ Picks up newly registered relays without code changes. - ✅ Gives you hooks to rank relays by price, latency, or trust level. - ❌ Still relies on static fee estimates (we will tackle that in the next lesson). --- ## Wrap-Up You now have a discovery pipeline that: - ✅ Queries RelayHub for fresh relay registrations. - ✅ Validates each relay's network, version, balance, and responsiveness. - ✅ Falls back gracefully when a relay fails health checks. - ✅ Removes the last hardcoded relay URL from your workflow. Next up: **Optimized Fee Calculation**, where you replace static fees with a dynamic, production-ready calculation. ### Solution Code #### index.js ```js import { HttpClient, HttpWrapper } from '@opengsn/common' import { TypedRequestData } from '@opengsn/common/dist/EIP712/TypedRequestData.js' import { ethers } from 'ethers' // 🔐 Paste your private key from Lesson 1 here! const PRIVATE_KEY = '0xPASTE_YOUR_PRIVATE_KEY_HERE_FROM_LESSON_1' // Mainnet addresses const POLYGON_RPC_URL = 'https://polygon-rpc.com' const USDT_ADDRESS = '0xc2132D05D31c914a87C6611C10748AEb04B58e8F' const TRANSFER_CONTRACT_ADDRESS = '0x98E69a6927747339d5E543586FC0262112eBe4BD' const RELAY_HUB_ADDRESS = '0x6C28AfC105e65782D9Ea6F2cA68df84C9e7d750d' const RECEIVER_ADDRESS = '0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184' const TRANSFER_AMOUNT_USDT = '0.01' const STATIC_FEE_USDT = '0.01' // ABIs const USDT_ABI = [ 'function balanceOf(address) view returns (uint256)', 'function nonces(address) view returns (uint256)', ] const TRANSFER_ABI = [ 'function transferWithApproval(address token, uint256 amount, address target, uint256 fee, uint256 approval, bytes32 sigR, bytes32 sigS, uint8 sigV)', 'function getNonce(address) view returns (uint256)', ] const RELAY_HUB_ABI = [ 'event RelayServerRegistered(address indexed relayManager, uint256 baseRelayFee, uint256 pctRelayFee, string relayUrl)', ] async function discoverRelays(provider) { console.log('\n🔍 Discovering relays from RelayHub...') const relayHub = new ethers.Contract(RELAY_HUB_ADDRESS, RELAY_HUB_ABI, provider) const currentBlock = await provider.getBlockNumber() const LOOKBACK_BLOCKS = 14400 // ~10 hours on Polygon (2 blocks/min * 60 * 10) const fromBlock = currentBlock - LOOKBACK_BLOCKS console.log(`Scanning blocks ${fromBlock} to ${currentBlock} (~10 hours)...`) const events = await relayHub.queryFilter( relayHub.filters.RelayServerRegistered(), fromBlock, currentBlock, ) console.log(`Found ${events.length} relay registration events`) // Extract unique relays const relayMap = new Map() for (const event of events) { const { relayManager, baseRelayFee, pctRelayFee, relayUrl } = event.args relayMap.set(relayUrl, { url: relayUrl, manager: relayManager, baseRelayFee: baseRelayFee.toString(), pctRelayFee: pctRelayFee.toString(), }) } const uniqueRelays = Array.from(relayMap.values()) console.log(`Found ${uniqueRelays.length} unique relay URLs`) return uniqueRelays } async function validateRelay(relay, provider) { try { // Ping relay with timeout const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 10000) const response = await fetch(`${relay.url}/getaddr`, { signal: controller.signal, }) clearTimeout(timeout) if (!response.ok) return null const relayInfo = await response.json() // Check version (must be 2.x) if (!relayInfo.version || !relayInfo.version.startsWith('2.')) { console.log(` ❌ ${relay.url} - wrong version: ${relayInfo.version}`) return null } // Check network (must be Polygon mainnet) if (relayInfo.networkId !== '137' && relayInfo.chainId !== '137') { console.log(` ❌ ${relay.url} - wrong network: ${relayInfo.networkId || relayInfo.chainId}`) return null } // Check ready status if (!relayInfo.ready) { console.log(` ❌ ${relay.url} - not ready`) return null } // Check worker balance const workerBalance = await provider.getBalance(relayInfo.relayWorkerAddress) const minBalance = ethers.utils.parseEther('0.01') // 0.01 POL minimum if (workerBalance.lt(minBalance)) { console.log(` ❌ ${relay.url} - low balance: ${ethers.utils.formatEther(workerBalance)} POL`) return null } // Check fee limits (max 70% percentage, 0 base fee) const pctFee = Number.parseInt(relay.pctRelayFee) const baseFee = ethers.BigNumber.from(relay.baseRelayFee) if (pctFee > 70) { console.log(` ❌ ${relay.url} - fee too high: ${pctFee}%`) return null } if (baseFee.gt(0)) { console.log(` ❌ ${relay.url} - base fee not acceptable: ${baseFee.toString()}`) return null } console.log(` ✅ ${relay.url} - valid (${pctFee}% fee, ${ethers.utils.formatEther(workerBalance)} POL)`) return { ...relay, relayWorkerAddress: relayInfo.relayWorkerAddress, minGasPrice: relayInfo.minGasPrice, version: relayInfo.version, } } catch (error) { console.log(` ❌ ${relay.url} - ${error.message}`) return null } } async function findBestRelay(provider) { const relays = await discoverRelays(provider) console.log('\n🔬 Validating relays...') for (const relay of relays) { const validRelay = await validateRelay(relay, provider) if (validRelay) { return validRelay } } throw new Error('No valid relays found') } async function main() { console.log('🚀 Gasless USDT transfer with relay discovery...\n') const provider = new ethers.providers.JsonRpcProvider(POLYGON_RPC_URL) const wallet = new ethers.Wallet(PRIVATE_KEY, provider) console.log('🔑 Sender:', wallet.address) console.log('📍 Receiver:', RECEIVER_ADDRESS) // Discover and validate relays const relay = await findBestRelay(provider) console.log('\n✅ Using relay:', relay.url) console.log(' Worker:', relay.relayWorkerAddress) console.log(' Fee:', `${relay.pctRelayFee}% + ${relay.baseRelayFee} wei`) // Rest of the gasless transaction logic (same as lesson 4) const usdt = new ethers.Contract(USDT_ADDRESS, USDT_ABI, provider) const usdtNonce = await usdt.nonces(wallet.address) const transferAmount = ethers.utils.parseUnits(TRANSFER_AMOUNT_USDT, 6) const feeAmount = ethers.utils.parseUnits(STATIC_FEE_USDT, 6) const approvalAmount = transferAmount.add(feeAmount) // Sign USDT approval const approveFunctionSignature = usdt.interface.encodeFunctionData('approve', [ TRANSFER_CONTRACT_ADDRESS, approvalAmount, ]) const usdtDomain = { name: 'USDT0', version: '1', verifyingContract: USDT_ADDRESS, salt: ethers.utils.hexZeroPad(ethers.utils.hexlify(137), 32), } const usdtTypes = { MetaTransaction: [ { name: 'nonce', type: 'uint256' }, { name: 'from', type: 'address' }, { name: 'functionSignature', type: 'bytes' }, ], } const usdtMessage = { nonce: usdtNonce.toNumber(), from: wallet.address, functionSignature: approveFunctionSignature, } const approvalSignature = await wallet._signTypedData(usdtDomain, usdtTypes, usdtMessage) const { r: sigR, s: sigS, v: sigV } = ethers.utils.splitSignature(approvalSignature) // Build transfer calldata const transferContract = new ethers.Contract(TRANSFER_CONTRACT_ADDRESS, TRANSFER_ABI, provider) const transferCalldata = transferContract.interface.encodeFunctionData('transferWithApproval', [ USDT_ADDRESS, transferAmount, RECEIVER_ADDRESS, feeAmount, approvalAmount, sigR, sigS, sigV, ]) // Build relay request const forwarderNonce = await transferContract.getNonce(wallet.address) const currentBlock = await provider.getBlockNumber() const validUntil = currentBlock + (2 * 60 * 2) // 2 hours const relayRequest = { request: { from: wallet.address, to: TRANSFER_CONTRACT_ADDRESS, value: '0', gas: '300000', nonce: forwarderNonce.toString(), data: transferCalldata, validUntil: validUntil.toString(), }, relayData: { gasPrice: '100000000000', // 100 gwei pctRelayFee: relay.pctRelayFee, baseRelayFee: relay.baseRelayFee, relayWorker: relay.relayWorkerAddress, paymaster: TRANSFER_CONTRACT_ADDRESS, forwarder: TRANSFER_CONTRACT_ADDRESS, paymasterData: '0x', clientId: '1', }, } // Sign relay request const forwarderDomain = { name: 'Forwarder', version: '1', chainId: 137, verifyingContract: TRANSFER_CONTRACT_ADDRESS, } const typedData = new TypedRequestData( forwarderDomain.chainId, forwarderDomain.verifyingContract, relayRequest, ) const { EIP712Domain, ...cleanedTypes } = typedData.types const relaySignature = await wallet._signTypedData(typedData.domain, cleanedTypes, typedData.message) // Submit to relay console.log('\n📡 Submitting to relay...') const relayNonce = await provider.getTransactionCount(relay.relayWorkerAddress) const httpClient = new HttpClient(new HttpWrapper(), console) const relayResponse = await httpClient.relayTransaction(relay.url, { relayRequest, metadata: { signature: relaySignature, approvalData: '0x', relayHubAddress: RELAY_HUB_ADDRESS, relayMaxNonce: relayNonce + 3, }, }) const txHash = typeof relayResponse === 'string' ? relayResponse : relayResponse.signedTx || relayResponse.txHash console.log('\n✅ Gasless transaction sent!') console.log('🔗 View:', `https://polygonscan.com/tx/${txHash}`) console.log('\n💡 Relay discovered dynamically - no hardcoded URLs!') } main().catch((error) => { console.error('\n❌ Error:', error.message) }) ``` #### package.json ```json { "name": "gasless-advanced", "type": "module", "version": "1.0.0", "scripts": { "discover": "node --watch index.js", "optimized": "node --watch index.js" }, "dependencies": { "@opengsn/common": "^2.2.6", "axios": "^1.6.0", "ethers": "^5.7.2" } } ``` --- ## Lesson 6: Optimized Fee Calculation --- type: lesson title: "Optimized Fee Calculation" focus: /index.js mainCommand: npm run optimized prepareCommands: - npm install terminal: open: true activePanel: 0 panels: ['output'] --- # Optimized Fee Calculation Hardcoding relay fees works for prototypes, but production systems need to adapt to market conditions in real time. In this lesson you will calculate the exact fee a relay should receive based on live gas prices, relay-specific pricing, and protective buffers — mirroring the logic in the Nimiq wallet. --- ## Learning Goals - Fetch the current network gas price and respect the relay's minimum. - Apply context-aware buffers that keep transactions reliable without overspending. - Combine relay percentage fees and base fees into a single POL amount. - Convert that POL cost into USDT using conservative pricing assumptions. - Compare multiple relays and pick the most cost-effective option. The maths mirrors the fee calculation used in the Nimiq wallet. Cross-reference it with the [OpenGSN fee model documentation](https://docs.opengsn.org/relay/relay-lifecycle.html#fees) and the [Nimiq Developer Center](https://developers.nimiq.com/) if you want to see the production lineage. --- ## The Core Formula ```text title="Fee formula" chainTokenFee = (gasPrice * gasLimit * (1 + pctRelayFee/100)) + baseRelayFee usdtFee = (chainTokenFee / usdtPriceInPOL) * safetyBuffer ``` Where: - `gasPrice` is the higher of the network price and the relay's minimum, optionally buffered. - `gasLimit` depends on the method you are executing. - `pctRelayFee` and `baseRelayFee` come from the relay registration event. - `safetyBuffer` adds wiggle room (typically 10-50%). - `usdtPriceInPOL` converts the POL cost into USDT. --- ## Step 1: Read the Network Gas Price ```js title="gas-price.ts" showLineNumbers mark=1-8 const networkGasPrice = await provider.getGasPrice() console.log('Network gas price:', ethers.utils.formatUnits(networkGasPrice, 'gwei'), 'gwei') // Get relay's minimum (from the relay discovery lesson) const relay = await discoverRelay() const minGasPrice = ethers.BigNumber.from(relay.minGasPrice) // Take the max const gasPrice = networkGasPrice.gt(minGasPrice) ? networkGasPrice : minGasPrice ``` Using the maximum of the two ensures you never pay less than the relay requires, while still benefiting from low network prices when possible. Why the **min gas price** matters: each relay advertises a floor in its `/getaddr` response. If you submit a transaction below that price the worker will reject it. The [Polygon gas market guide](https://docs.polygon.technology/docs/develop/network-details/gas/) explains why congestion spikes make this floor fluctuate. --- ## Step 2: Apply a Safety Buffer Different workflows tolerate risk differently. Adjust the buffer based on the method or environment you are in. ```js title="buffer.ts" showLineNumbers mark=1-13 const ENV_MAIN = true // Set based on environment let bufferPercentage if (method === 'redeemWithSecretInData') { bufferPercentage = 150 // 50% buffer (swap fee volatility) } else if (ENV_MAIN) { bufferPercentage = 120 // 20% buffer (mainnet) } else { bufferPercentage = 125 // 25% buffer (testnet, more volatile) } const bufferedGasPrice = gasPrice.mul(bufferPercentage).div(100) console.log('Buffered gas price:', ethers.utils.formatUnits(bufferedGasPrice, 'gwei'), 'gwei') ``` --- ## Step 3: Get Gas Limit from Transfer Contract Instead of hardcoding gas limits, query the transfer contract directly: ```js title="gas-limit.ts" showLineNumbers mark=1-11 const TRANSFER_CONTRACT_ABI = [ 'function getRequiredRelayGas(bytes4 methodId) view returns (uint256)' ] const transferContract = new ethers.Contract( TRANSFER_CONTRACT_ADDRESS, TRANSFER_CONTRACT_ABI, provider ) // Method selector for transferWithApproval const METHOD_SELECTOR = '0x8d89149b' const gasLimit = await transferContract.getRequiredRelayGas(METHOD_SELECTOR) console.log('Gas limit:', gasLimit.toString()) ``` This ensures your gas estimates stay accurate even if the contract changes. --- ## Step 4: Combine Gas Costs and Relay Fees ```js title="fee-components.ts" showLineNumbers mark=1-14 // Calculate base cost const baseCost = bufferedGasPrice.mul(gasLimit) // Add relay percentage fee const pctRelayFee = 15 // From relay registration (e.g., 15%) const costWithPct = baseCost.mul(100 + pctRelayFee).div(100) // Add base relay fee const baseRelayFee = ethers.BigNumber.from(relay.baseRelayFee) const totalChainTokenFee = costWithPct.add(baseRelayFee) console.log('Total POL cost:', ethers.utils.formatEther(totalChainTokenFee)) ``` This yields the amount of POL the relay expects to receive after covering gas. --- ## Step 5: Get Real-Time POL/USDT Price from Uniswap Query Uniswap V3 for the current exchange rate: ```js title="uniswap-price.ts" showLineNumbers mark=1-21 const UNISWAP_QUOTER = '0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6' const WMATIC = '0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270' const USDT_WMATIC_POOL = '0x9B08288C3Be4F62bbf8d1C20Ac9C5e6f9467d8B7' const POOL_ABI = ['function fee() external view returns (uint24)'] const QUOTER_ABI = [ 'function quoteExactInputSingle(address tokenIn, address tokenOut, uint24 fee, uint256 amountIn, uint160 sqrtPriceLimitX96) external returns (uint256 amountOut)' ] async function getPolUsdtPrice(provider) { const pool = new ethers.Contract(USDT_WMATIC_POOL, POOL_ABI, provider) const quoter = new ethers.Contract(UNISWAP_QUOTER, QUOTER_ABI, provider) const fee = await pool.fee() // Quote: How much POL for 1 USDT? const polPerUsdt = await quoter.callStatic.quoteExactInputSingle( USDT_ADDRESS, WMATIC, fee, ethers.utils.parseUnits('1', 6), 0 ) return polPerUsdt // POL wei per 1 USDT } ``` --- ## Step 6: Convert POL Cost to USDT ```js title="fee-to-usdt.ts" showLineNumbers mark=1-8 const polPerUsdt = await getPolUsdtPrice(provider) // Convert: totalPOLCost / polPerUsdt = USDT units // No additional buffer - gas price buffer is sufficient const feeInUSDT = totalChainTokenFee .mul(1_000_000) .div(polPerUsdt) console.log('USDT fee:', ethers.utils.formatUnits(feeInUSDT, 6)) ``` Using Uniswap ensures your fees reflect current market rates, preventing underpayment when POL appreciates. --- ## Step 7: Reject Expensive Relays ```js title="guardrails.ts" showLineNumbers mark=1-9 const MAX_PCT_RELAY_FEE = 70 // Never accept >70% const MAX_BASE_RELAY_FEE = 0 // Never accept base fee if (pctRelayFee > MAX_PCT_RELAY_FEE) { throw new Error(`Relay fee too high: ${pctRelayFee}%`) } if (baseRelayFee.gt(MAX_BASE_RELAY_FEE)) { throw new Error('Relay base fee not acceptable') } ``` These guardrails prevent accidental overpayment when a relay is misconfigured or opportunistic. --- ## Step 8: Choose the Best Relay ```js title="choose-relay.ts" showLineNumbers mark=1-17 async function getBestRelay(relays, gasLimit, method) { let bestRelay = null let lowestFee = ethers.constants.MaxUint256 for (const relay of relays) { try { const fee = await calculateFee(relay, gasLimit, method) if (fee.lt(lowestFee)) { lowestFee = fee bestRelay = relay } } catch (error) { continue // Skip invalid relays } } return bestRelay } ``` When you have multiple candidates, this loop compares their fees and picks the cheapest valid option. Want more than “cheapest wins”? It is common to score relays on latency, historical success rate, or geographic proximity. The [OpenGSN Relay Operator checklist](https://docs.opengsn.org/relay/relay-operator.html) lists the metrics most teams monitor. --- ## Production Considerations These extras come straight from the Nimiq wallet codebase: 1. **Timeout relay requests after two seconds** to avoid hanging the UI. 2. **Cap the number of relays you test** (for example, at 10) to keep discovery fast. 3. **Require worker balances at least twice the expected gas cost** for safety. 4. **Skip inactive relays** unless they belong to trusted providers such as Fastspot. 5. **Use generators or iterators** so you can stop searching the moment a good relay appears. --- ## Putting It All Together ```js title="calculate-optimal-fee.ts" showLineNumbers mark=1-21 async function calculateOptimalFee(relay, provider, transferContract) { // 1. Get gas prices const networkGasPrice = await provider.getGasPrice() const baseGasPrice = networkGasPrice.gt(relay.minGasPrice) ? networkGasPrice : relay.minGasPrice // 2. Apply buffer const bufferedGasPrice = baseGasPrice.mul(120).div(100) // 20% mainnet // 3. Get gas limit from contract const gasLimit = await transferContract.getRequiredRelayGas('0x8d89149b') // 4. Calculate POL fee const baseCost = bufferedGasPrice.mul(gasLimit) const withPctFee = baseCost.mul(100 + relay.pctRelayFee).div(100) const totalPOL = withPctFee.add(relay.baseRelayFee) // 5. Convert to USDT via Uniswap const polPerUsdt = await getPolUsdtPrice(provider) const usdtFee = totalPOL.mul(1_000_000).div(polPerUsdt) return { usdtFee, gasPrice: bufferedGasPrice, gasLimit } } ``` Reuse this helper whenever you prepare a meta-transaction so each request reflects current network conditions. --- ## Wrap-Up You now have a production-grade fee engine that: - ✅ Tracks live gas prices and relay minimums. - ✅ Queries contract for accurate gas limits. - ✅ Uses Uniswap V3 for real-time POL/USDT rates. - ✅ Applies thoughtful buffers to avoid underpayment. - ✅ Compares relays and selects the most cost-effective option. At this point your gasless transaction pipeline matches the approach we ship in the Nimiq wallet - ready for real users. The next lesson covers USDC transfers using the EIP-2612 permit approval method, showing how different tokens require different approval strategies. ### Solution Code #### index.js ```js import { HttpClient, HttpWrapper } from '@opengsn/common' import { TypedRequestData } from '@opengsn/common/dist/EIP712/TypedRequestData.js' import { ethers } from 'ethers' // 🔐 Paste your private key from Lesson 1 here! const PRIVATE_KEY = '0xPASTE_YOUR_PRIVATE_KEY_HERE_FROM_LESSON_1' // Mainnet addresses const POLYGON_RPC_URL = 'https://polygon-rpc.com' const USDT_ADDRESS = '0xc2132D05D31c914a87C6611C10748AEb04B58e8F' const TRANSFER_CONTRACT_ADDRESS = '0x98E69a6927747339d5E543586FC0262112eBe4BD' const RELAY_HUB_ADDRESS = '0x6C28AfC105e65782D9Ea6F2cA68df84C9e7d750d' const RECEIVER_ADDRESS = '0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184' const TRANSFER_AMOUNT_USDT = '0.01' // Uniswap V3 addresses for price queries const UNISWAP_QUOTER = '0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6' const WMATIC_ADDRESS = '0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270' const USDT_WMATIC_POOL = '0x9B08288C3Be4F62bbf8d1C20Ac9C5e6f9467d8B7' // Method selector for transferWithApproval const METHOD_SELECTOR_TRANSFER_WITH_APPROVAL = '0x8d89149b' // ABIs const USDT_ABI = [ 'function balanceOf(address) view returns (uint256)', 'function nonces(address) view returns (uint256)', ] const TRANSFER_ABI = [ 'function transferWithApproval(address token, uint256 amount, address target, uint256 fee, uint256 approval, bytes32 sigR, bytes32 sigS, uint8 sigV)', 'function getNonce(address) view returns (uint256)', 'function getRequiredRelayGas(bytes4 methodId) view returns (uint256)', ] const RELAY_HUB_ABI = [ 'event RelayServerRegistered(address indexed relayManager, uint256 baseRelayFee, uint256 pctRelayFee, string relayUrl)', ] const UNISWAP_POOL_ABI = [ 'function fee() external view returns (uint24)', ] const UNISWAP_QUOTER_ABI = [ 'function quoteExactInputSingle(address tokenIn, address tokenOut, uint24 fee, uint256 amountIn, uint160 sqrtPriceLimitX96) external returns (uint256 amountOut)', ] async function discoverRelays(provider) { const relayHub = new ethers.Contract(RELAY_HUB_ADDRESS, RELAY_HUB_ABI, provider) const currentBlock = await provider.getBlockNumber() const LOOKBACK_BLOCKS = 1800 // ~1 hour (30 blocks/min * 60 min) const events = await relayHub.queryFilter( relayHub.filters.RelayServerRegistered(), currentBlock - LOOKBACK_BLOCKS, currentBlock, ) const relayMap = new Map() for (const event of events) { const { relayManager, baseRelayFee, pctRelayFee, relayUrl } = event.args relayMap.set(relayUrl, { url: relayUrl, manager: relayManager, baseRelayFee, pctRelayFee: pctRelayFee.toNumber(), }) } return Array.from(relayMap.values()) } async function validateRelay(relay, provider) { try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 10000) const response = await fetch(`${relay.url}/getaddr`, { signal: controller.signal }) clearTimeout(timeout) if (!response.ok) return null const relayInfo = await response.json() // Basic validation if (!relayInfo.version?.startsWith('2.')) return null if (relayInfo.networkId !== '137' && relayInfo.chainId !== '137') return null if (!relayInfo.ready) return null const workerBalance = await provider.getBalance(relayInfo.relayWorkerAddress) if (workerBalance.lt(ethers.utils.parseEther('0.01'))) return null // Fee validation if (relay.pctRelayFee > 70) return null if (relay.baseRelayFee.gt(0)) return null return { ...relay, relayWorkerAddress: relayInfo.relayWorkerAddress, minGasPrice: ethers.BigNumber.from(relayInfo.minGasPrice || 0), version: relayInfo.version, } } catch { return null } } async function getPolUsdtPrice(provider) { // Query Uniswap V3 pool for USDT/WMATIC price const pool = new ethers.Contract(USDT_WMATIC_POOL, UNISWAP_POOL_ABI, provider) const quoter = new ethers.Contract(UNISWAP_QUOTER, UNISWAP_QUOTER_ABI, provider) // Get pool fee tier const fee = await pool.fee() // Quote: How much POL for 1 USDT (1_000_000 base units)? const polAmountOut = await quoter.callStatic.quoteExactInputSingle( USDT_ADDRESS, // tokenIn (USDT) WMATIC_ADDRESS, // tokenOut (WMATIC) fee, // pool fee ethers.utils.parseUnits('1', 6), // 1 USDT 0, // sqrtPriceLimitX96 ) return polAmountOut // POL wei per 1 USDT } async function calculateOptimalFee(relay, provider, transferContract, isMainnet = true) { // Step 1: Get network gas price const networkGasPrice = await provider.getGasPrice() console.log(' Network gas price:', ethers.utils.formatUnits(networkGasPrice, 'gwei'), 'gwei') console.log(' Relay min gas price:', ethers.utils.formatUnits(relay.minGasPrice, 'gwei'), 'gwei') // Step 2: Take max of network and relay minimum const baseGasPrice = networkGasPrice.gt(relay.minGasPrice) ? networkGasPrice : relay.minGasPrice // Step 3: Apply buffer (20% mainnet, 25% testnet) const bufferPercentage = isMainnet ? 120 : 125 const bufferedGasPrice = baseGasPrice.mul(bufferPercentage).div(100) console.log(' Buffered gas price:', ethers.utils.formatUnits(bufferedGasPrice, 'gwei'), 'gwei', `(${bufferPercentage}%)`) // Step 4: Get gas limit from transfer contract const gasLimit = await transferContract.getRequiredRelayGas(METHOD_SELECTOR_TRANSFER_WITH_APPROVAL) console.log(' Gas limit:', gasLimit.toString()) // Step 5: Calculate base cost const baseCost = bufferedGasPrice.mul(gasLimit) // Step 6: Apply relay percentage fee const costWithPctFee = baseCost.mul(100 + relay.pctRelayFee).div(100) // Step 7: Add base relay fee const totalPOLCost = costWithPctFee.add(relay.baseRelayFee) console.log(' Total POL cost:', ethers.utils.formatEther(totalPOLCost), 'POL') console.log(' Relay fee:', `${relay.pctRelayFee}%`) // Step 8: Get real-time POL/USDT price from Uniswap const polPerUsdt = await getPolUsdtPrice(provider) console.log(' Uniswap rate:', ethers.utils.formatEther(polPerUsdt), 'POL per USDT') // Step 9: Convert POL fee to USDT // totalPOLCost (POL wei) / polPerUsdt (POL wei per USDT) = USDT base units const feeInUSDT = totalPOLCost.mul(1_000_000).div(polPerUsdt) console.log(' USDT fee:', ethers.utils.formatUnits(feeInUSDT, 6), 'USDT') return { usdtFee: feeInUSDT, gasPrice: bufferedGasPrice, gasLimit, polCost: totalPOLCost, } } async function findBestRelay(provider, transferContract) { console.log('\n🔍 Discovering relays...') const relays = await discoverRelays(provider) console.log(`Found ${relays.length} unique relay URLs`) console.log('\n🔬 Validating and calculating fees...\n') let bestRelay = null let lowestFee = ethers.constants.MaxUint256 for (const relay of relays) { const validRelay = await validateRelay(relay, provider) if (!validRelay) continue console.log(`📊 ${relay.url}`) try { const feeData = await calculateOptimalFee(validRelay, provider, transferContract) if (feeData.usdtFee.lt(lowestFee)) { lowestFee = feeData.usdtFee bestRelay = { ...validRelay, feeData } console.log(' ✅ New best relay!\n') } else { console.log(' ⚪ Not the cheapest\n') } } catch (error) { console.log(' ❌ Fee calculation failed:', error.message, '\n') } } if (!bestRelay) { throw new Error('No valid relays found') } return bestRelay } async function main() { console.log('🚀 Gasless USDT transfer with optimized fee calculation...\n') const provider = new ethers.providers.JsonRpcProvider(POLYGON_RPC_URL) const wallet = new ethers.Wallet(PRIVATE_KEY, provider) console.log('🔑 Sender:', wallet.address) console.log('📍 Receiver:', RECEIVER_ADDRESS) // Setup transfer contract const transferContract = new ethers.Contract(TRANSFER_CONTRACT_ADDRESS, TRANSFER_ABI, provider) // Find best relay with optimized fee const relay = await findBestRelay(provider, transferContract) console.log('\n✅ Selected relay:', relay.url) console.log(' Worker:', relay.relayWorkerAddress) console.log(' Optimized USDT fee:', ethers.utils.formatUnits(relay.feeData.usdtFee, 6), 'USDT') console.log(' Gas price:', ethers.utils.formatUnits(relay.feeData.gasPrice, 'gwei'), 'gwei') // Build transaction with calculated fee const usdt = new ethers.Contract(USDT_ADDRESS, USDT_ABI, provider) const usdtNonce = await usdt.nonces(wallet.address) const transferAmount = ethers.utils.parseUnits(TRANSFER_AMOUNT_USDT, 6) const feeAmount = relay.feeData.usdtFee // Use optimized fee const approvalAmount = transferAmount.add(feeAmount) console.log('\n💰 Transfer:', TRANSFER_AMOUNT_USDT, 'USDT') console.log('💸 Optimized fee:', ethers.utils.formatUnits(feeAmount, 6), 'USDT') console.log('✅ Total approval:', ethers.utils.formatUnits(approvalAmount, 6), 'USDT') // Sign USDT approval const approveFunctionSignature = usdt.interface.encodeFunctionData('approve', [ TRANSFER_CONTRACT_ADDRESS, approvalAmount, ]) const usdtDomain = { name: 'USDT0', version: '1', verifyingContract: USDT_ADDRESS, salt: ethers.utils.hexZeroPad(ethers.utils.hexlify(137), 32), } const usdtTypes = { MetaTransaction: [ { name: 'nonce', type: 'uint256' }, { name: 'from', type: 'address' }, { name: 'functionSignature', type: 'bytes' }, ], } const usdtMessage = { nonce: usdtNonce.toNumber(), from: wallet.address, functionSignature: approveFunctionSignature, } const approvalSignature = await wallet._signTypedData(usdtDomain, usdtTypes, usdtMessage) const { r: sigR, s: sigS, v: sigV } = ethers.utils.splitSignature(approvalSignature) // Build transfer calldata const transferCalldata = transferContract.interface.encodeFunctionData('transferWithApproval', [ USDT_ADDRESS, transferAmount, RECEIVER_ADDRESS, feeAmount, approvalAmount, sigR, sigS, sigV, ]) // Build relay request with optimized gas price const forwarderNonce = await transferContract.getNonce(wallet.address) const currentBlock = await provider.getBlockNumber() const validUntil = currentBlock + (2 * 60 * 2) // 2 hours const relayRequest = { request: { from: wallet.address, to: TRANSFER_CONTRACT_ADDRESS, value: '0', gas: relay.feeData.gasLimit.toString(), nonce: forwarderNonce.toString(), data: transferCalldata, validUntil: validUntil.toString(), }, relayData: { gasPrice: relay.feeData.gasPrice.toString(), pctRelayFee: relay.pctRelayFee.toString(), baseRelayFee: relay.baseRelayFee.toString(), relayWorker: relay.relayWorkerAddress, paymaster: TRANSFER_CONTRACT_ADDRESS, forwarder: TRANSFER_CONTRACT_ADDRESS, paymasterData: '0x', clientId: '1', }, } // Sign relay request const forwarderDomain = { name: 'Forwarder', version: '1', chainId: 137, verifyingContract: TRANSFER_CONTRACT_ADDRESS, } const typedData = new TypedRequestData( forwarderDomain.chainId, forwarderDomain.verifyingContract, relayRequest, ) const { EIP712Domain, ...cleanedTypes } = typedData.types const relaySignature = await wallet._signTypedData(typedData.domain, cleanedTypes, typedData.message) // Submit to relay console.log('\n📡 Submitting to relay...') const relayNonce = await provider.getTransactionCount(relay.relayWorkerAddress) const httpClient = new HttpClient(new HttpWrapper(), console) const relayResponse = await httpClient.relayTransaction(relay.url, { relayRequest, metadata: { signature: relaySignature, approvalData: '0x', relayHubAddress: RELAY_HUB_ADDRESS, relayMaxNonce: relayNonce + 3, }, }) const txHash = typeof relayResponse === 'string' ? relayResponse : relayResponse.signedTx || relayResponse.txHash console.log('\n✅ Gasless transaction sent!') console.log('🔗 View:', `https://polygonscan.com/tx/${txHash}`) console.log('\n💡 Used production-grade fee optimization:') console.log(' ✅ Dynamic gas price discovery') console.log(' ✅ Gas limits from contract (getRequiredRelayGas)') console.log(' ✅ Real-time POL/USDT pricing from Uniswap V3') console.log(' ✅ Network-aware safety buffers') console.log(' ✅ Relay fee comparison') } main().catch((error) => { console.error('\n❌ Error:', error.message) }) ``` #### package.json ```json { "name": "gasless-advanced", "type": "module", "version": "1.0.0", "scripts": { "discover": "node --watch index.js", "optimized": "node --watch index.js" }, "dependencies": { "@opengsn/common": "^2.2.6", "axios": "^1.6.0", "ethers": "^5.7.2" } } ``` --- ## Lesson 7: USDC with EIP-2612 Permit --- type: lesson title: "USDC with EIP-2612 Permit" focus: /index.js mainCommand: npm run usdc prepareCommands: - npm install terminal: open: true activePanel: 0 panels: ['output'] --- # USDC with EIP-2612 Permit Earlier in this section you built gasless USDT transfers using a custom meta-transaction approval. USDC ships with a standardized approval flow called **EIP-2612 Permit**. This lesson explains what that standard changes, why it exists, and how to adapt the gasless pipeline you already wrote so that USDC transfers go through the same relay infrastructure. --- ## Learning Goals - Understand when to prefer EIP-2612 Permit instead of custom meta-transaction approvals. - Sign permit messages that use the version + chainId domain separator defined by EIP-2612. - Swap the `transferWithApproval` call for the USDC-specific `transferWithPermit`. - Adjust fee calculations to respect USDC's 6-decimal precision and Polygon USD pricing. You already know the broader flow: discover relays, compute fees, sign an approval, relay the transaction. The only moving pieces are the approval signature and the calldata that consumes it. Everything else stays intact. --- ## Background: What EIP-2612 Adds EIP-2612 is an extension of ERC-20 that lets a token holder authorize spending via an off-chain signature instead of an on-chain `approve()` transaction. The signature uses the shared EIP-712 typed-data format: - **Domain separator** includes the token name, version, chainId, and contract address so signatures cannot be replayed across chains or forks. - **Permit struct** defines the spender, allowance value, and deadline in a predictable shape. Tokens like USDC, DAI, and WETH adopted the standard because it enables wallets and relayers to cover approval gas costs while staying interoperable with any contract that understands permits (for example, Uniswap routers or Aave). Older tokens such as USDT predate EIP-2612, so they expose custom meta-transaction logic instead. That is why the gasless USDT lesson had to sign the entire `transferWithApproval` function payload, whereas USDC only needs the numeric values that describe the allowance. --- ## EIP-2612 Permit vs Meta-Transaction ### USDT Meta-Transaction (Lesson 6) ```js // Salt-based domain separator const domain = { name: 'USDT0', version: '1', verifyingContract: USDT_ADDRESS, salt: ethers.utils.hexZeroPad(ethers.utils.hexlify(137), 32), // ⚠️ Salt, not chainId } const types = { MetaTransaction: [ { name: 'nonce', type: 'uint256' }, { name: 'from', type: 'address' }, { name: 'functionSignature', type: 'bytes' }, // ⚠️ Encoded function call ], } ``` ### USDC Permit (This Lesson) ```js // Version-based domain separator (EIP-2612 standard) const domain = { name: 'USD Coin', version: '2', chainId: 137, // ✅ Standard chainId verifyingContract: USDC_ADDRESS, } const types = { Permit: [ { name: 'owner', type: 'address' }, { name: 'spender', type: 'address' }, { name: 'value', type: 'uint256' }, { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint256' }, // ✅ Deadline, not functionSignature ], } ``` --- ## Key Differences | Aspect | USDT Meta-Transaction (Lesson 6) | USDC Permit (This Lesson) | | -------------------- | ------------------------------------ | ----------------------------- | | **Standardization** | Custom, tether-specific | Formalized in EIP-2612 | | **Domain separator** | Uses `salt` derived from chain | Uses `version` plus `chainId` | | **Typed struct** | `MetaTransaction` with encoded bytes | `Permit` with discrete fields | | **Expiry control** | No expiration | Explicit `deadline` | | **Transfer helper** | `transferWithApproval` | `transferWithPermit` | | **Method selector** | `0x8d89149b` | `0x36efd16f` | Keep this table nearby while refactoring; you will touch each of these rows as you migrate the code. --- ## Step 1: Update Contract Addresses ```js title="usdc-config.js" showLineNumbers mark=1-5 const USDC_ADDRESS = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' const TRANSFER_CONTRACT_ADDRESS = '0x3157d422cd1be13AC4a7cb00957ed717e648DFf2' const USDC_WMATIC_POOL = '0xA374094527e1673A86dE625aa59517c5dE346d32' const METHOD_SELECTOR_TRANSFER_WITH_PERMIT = '0x36efd16f' ``` USDC relies on a different relay contract and Uniswap pool than USDT on Polygon. Updating the constants up front prevents subtle bugs later on. For example, querying gas data against the wrong selector yields an optimistic fee that fails on-chain. --- ## Step 2: Sign EIP-2612 Permit ```js title="permit-signature.js" showLineNumbers mark=1-29 const usdc = new ethers.Contract(USDC_ADDRESS, USDC_ABI, provider) const usdcNonce = await usdc.nonces(wallet.address) const transferAmount = ethers.utils.parseUnits('0.01', 6) const feeAmount = relay.feeData.usdcFee const approvalAmount = transferAmount.add(feeAmount) // EIP-2612 domain const domain = { name: 'USD Coin', version: '2', chainId: 137, verifyingContract: USDC_ADDRESS, } // EIP-2612 Permit struct const types = { Permit: [ { name: 'owner', type: 'address' }, { name: 'spender', type: 'address' }, { name: 'value', type: 'uint256' }, { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint256' }, ], } const message = { owner: wallet.address, spender: TRANSFER_CONTRACT_ADDRESS, value: approvalAmount, nonce: usdcNonce.toNumber(), deadline: ethers.constants.MaxUint256, // ✅ Infinite deadline } const signature = await wallet._signTypedData(domain, types, message) const { r, s, v } = ethers.utils.splitSignature(signature) ``` Key callouts: - Fetch the **nonce** from the USDC contract itself. EIP-2612 uses a per-owner nonce to prevent replay. - Calculate the **approval value** as `transfer + fee`. A permit is just an allowance, so the relay must be allowed to withdraw both the payment to the recipient and its compensation. - USDC accepts a `MaxUint256` deadline, but production systems usually set a shorter deadline (for example `Math.floor(Date.now() / 1000) + 3600`) to minimize replay windows. --- ## Step 3: Build transferWithPermit Call ```js title="transfer-calldata.js" showLineNumbers mark=1-9 const transferContract = new ethers.Contract( TRANSFER_CONTRACT_ADDRESS, TRANSFER_ABI, provider ) const transferCalldata = transferContract.interface.encodeFunctionData('transferWithPermit', [ USDC_ADDRESS, // token transferAmount, // amount RECEIVER_ADDRESS, // target feeAmount, // fee approvalAmount, // value (approval amount, not deadline) r, // sigR s, // sigS v, // sigV ]) ``` `transferWithPermit` consumes the permit signature directly. Compare this to the USDT version: instead of passing an encoded `approve()` call, you now hand the relay the raw signature components. The 5th parameter is the approval `value` (how much the contract can spend), not the permit deadline. If you changed the permit `value` when signing, make sure the same amount is passed to `transferWithPermit`. The deadline from the permit signature is used internally by the token contract. --- ## Step 4: Update Fee Calculation ```js title="usdc-fees.js" showLineNumbers mark=1-11 // Use USDC-specific method selector const METHOD_SELECTOR = '0x36efd16f' // transferWithPermit const gasLimit = await transferContract.getRequiredRelayGas(METHOD_SELECTOR) // Use USDC/WMATIC Uniswap pool const USDC_WMATIC_POOL = '0xA374094527e1673A86dE625aa59517c5dE346d32' const polPerUsdc = await getPolUsdcPrice(provider) // Query USDC pool const feeInUSDC = totalPOLCost.mul(1_000_000).div(polPerUsdc).mul(110).div(100) ``` - `getRequiredRelayGas` evaluates the gas buffer the forwarder demands for `transferWithPermit`. It usually matches the USDT value (~72,000 gas) but querying removes guesswork. - USDC keeps **6 decimals**, so multiply/divide by `1_000_000` when converting between POL and USDC. Avoid using `ethers.utils.parseUnits(..., 18)` out of habit. - The `polPerUsdc` helper should target the USDC/WMATIC pool; pricing against USDT would skew the fee at times when the two stablecoins diverge. --- ## Step 5: Rest of Flow Stays the Same After building the transfer calldata, the rest is identical to the gasless USDT lesson: 1. Get forwarder nonce from transfer contract 2. Build OpenGSN `ForwardRequest` 3. Sign ForwardRequest with EIP-712 4. Submit to relay via `HttpClient` 5. Broadcast transaction If you already wrapped these steps in helper functions, you should not need to touch them. The permit signature simply slots into the existing request payload where the USDT approval bytes previously sat. --- ## ABI Changes ```js title="usdc-abi.js" showLineNumbers mark=1-5 const TRANSFER_ABI = [ // Changed from transferWithApproval 'function transferWithPermit(address token, uint256 amount, address target, uint256 fee, uint256 value, bytes32 sigR, bytes32 sigS, uint8 sigV)', 'function getNonce(address) view returns (uint256)', 'function getRequiredRelayGas(bytes4 methodId) view returns (uint256)', ] ``` `transferWithPermit` mirrors OpenZeppelin's relay helper, so the ABI change is straightforward. Keeping the ABI narrowly scoped makes tree-shaking easier if you bundle the tutorial for production later. --- ## Why Two Approval Methods? **USDT** (pre-EIP-2612 era): - Custom meta-transaction implementation - Salt-based domain separator - Encodes full function call in signature **USDC** (EIP-2612 compliant): - Standardized permit interface - Version + chainId domain separator - Simpler parameter structure Most modern tokens (DAI, USDC, WBTC on some chains) support EIP-2612. Older tokens like USDT use custom approaches. --- ## Testing and Troubleshooting - **Signature mismatch**: Double-check that `domain.name` exactly matches the on-chain token name. For USDC on Polygon it is `"USD Coin"`; capitalization matters. - **Invalid deadline**: If the relay says the permit expired, inspect the value you passed to `deadline` and ensure your local clock is not skewed. - **Allowance too low**: If the recipient receives funds but the relay reverts, print the computed `feeAmount` and make sure the permit covered both transfer and fee. Running `npm run usdc` after each change keeps the feedback loop tight and mirrors how the Nimiq wallet tests the same flow. --- ## Production Considerations 1. **Check token support**: Not all ERC20s have permit. Fallback to standard `approve()` + `transferFrom()` if needed. 2. **Deadline vs MaxUint256**: Production systems often use block-based deadlines (e.g., `currentBlock + 100`) for tighter security. 3. **Domain parameters**: Always verify `name` and `version` match the token contract - wrong values = invalid signature. 4. **Method selector lookup**: Store selectors in config per token to avoid hardcoding. 5. **Permit reuse policy**: Decide whether to reuse a permit for multiple transfers or issue a fresh one per relay request. Fresh permits simplify accounting but require re-signing each time. --- ## Wrap-Up You now support gasless transfers for both USDT (custom meta-transaction) and USDC (EIP-2612 permit). Keep these takeaways in mind: - ✅ Approval strategies vary across tokens; detect the capability before deciding on the flow. - ✅ EIP-2612 standardizes the permit format: domain fields and struct definitions must match exactly. - ✅ `transferWithPermit` lets you drop the bulky encoded function signature and pass raw signature parts instead. - ✅ Fee and relay logic remain unchanged once the calldata is assembled correctly. You now have a complete gasless transaction system matching the Nimiq wallet implementation, ready for production use on Polygon mainnet. ### Solution Code #### index.js ```js import { HttpClient, HttpWrapper } from '@opengsn/common' import { TypedRequestData } from '@opengsn/common/dist/EIP712/TypedRequestData.js' import { ethers } from 'ethers' // 🔐 Paste your private key from Lesson 1 here! const PRIVATE_KEY = '0xPASTE_YOUR_PRIVATE_KEY_HERE_FROM_LESSON_1' // Mainnet addresses const POLYGON_RPC_URL = 'https://polygon-rpc.com' const USDC_ADDRESS = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' const TRANSFER_CONTRACT_ADDRESS = '0x3157d422cd1be13AC4a7cb00957ed717e648DFf2' const RELAY_HUB_ADDRESS = '0x6C28AfC105e65782D9Ea6F2cA68df84C9e7d750d' const RECEIVER_ADDRESS = '0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184' const TRANSFER_AMOUNT_USDC = '0.01' // Uniswap V3 addresses for price queries const UNISWAP_QUOTER = '0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6' const WMATIC_ADDRESS = '0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270' const USDC_WMATIC_POOL = '0xA374094527e1673A86dE625aa59517c5dE346d32' // Method selector for transferWithPermit const METHOD_SELECTOR_TRANSFER_WITH_PERMIT = '0x36efd16f' // ABIs const USDC_ABI = [ 'function balanceOf(address) view returns (uint256)', 'function nonces(address) view returns (uint256)', ] const TRANSFER_ABI = [ 'function transferWithPermit(address token, uint256 amount, address target, uint256 fee, uint256 value, bytes32 sigR, bytes32 sigS, uint8 sigV)', 'function getNonce(address) view returns (uint256)', 'function getRequiredRelayGas(bytes4 methodId) view returns (uint256)', ] const RELAY_HUB_ABI = [ 'event RelayServerRegistered(address indexed relayManager, uint256 baseRelayFee, uint256 pctRelayFee, string relayUrl)', ] const UNISWAP_POOL_ABI = [ 'function fee() external view returns (uint24)', ] const UNISWAP_QUOTER_ABI = [ 'function quoteExactInputSingle(address tokenIn, address tokenOut, uint24 fee, uint256 amountIn, uint160 sqrtPriceLimitX96) external returns (uint256 amountOut)', ] async function discoverRelays(provider) { const relayHub = new ethers.Contract(RELAY_HUB_ADDRESS, RELAY_HUB_ABI, provider) const currentBlock = await provider.getBlockNumber() const LOOKBACK_BLOCKS = 1800 // ~1 hour (30 blocks/min * 60 min) const events = await relayHub.queryFilter( relayHub.filters.RelayServerRegistered(), currentBlock - LOOKBACK_BLOCKS, currentBlock, ) const relayMap = new Map() for (const event of events) { const { relayManager, baseRelayFee, pctRelayFee, relayUrl } = event.args relayMap.set(relayUrl, { url: relayUrl, manager: relayManager, baseRelayFee, pctRelayFee: pctRelayFee.toNumber(), }) } return Array.from(relayMap.values()) } async function validateRelay(relay, provider) { try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 10000) const response = await fetch(`${relay.url}/getaddr`, { signal: controller.signal }) clearTimeout(timeout) if (!response.ok) return null const relayInfo = await response.json() // Basic validation if (!relayInfo.version?.startsWith('2.')) return null if (relayInfo.networkId !== '137' && relayInfo.chainId !== '137') return null if (!relayInfo.ready) return null const workerBalance = await provider.getBalance(relayInfo.relayWorkerAddress) if (workerBalance.lt(ethers.utils.parseEther('0.01'))) return null // Fee validation if (relay.pctRelayFee > 70) return null if (relay.baseRelayFee.gt(0)) return null return { ...relay, relayWorkerAddress: relayInfo.relayWorkerAddress, minGasPrice: ethers.BigNumber.from(relayInfo.minGasPrice || 0), version: relayInfo.version, } } catch { return null } } async function getPolUsdcPrice(provider) { // Query Uniswap V3 pool for USDC/WMATIC price const pool = new ethers.Contract(USDC_WMATIC_POOL, UNISWAP_POOL_ABI, provider) const quoter = new ethers.Contract(UNISWAP_QUOTER, UNISWAP_QUOTER_ABI, provider) // Get pool fee tier const fee = await pool.fee() // Quote: How much POL for 1 USDC (1_000_000 base units)? const polAmountOut = await quoter.callStatic.quoteExactInputSingle( USDC_ADDRESS, // tokenIn (USDC) WMATIC_ADDRESS, // tokenOut (WMATIC) fee, // pool fee ethers.utils.parseUnits('1', 6), // 1 USDC 0, // sqrtPriceLimitX96 ) return polAmountOut // POL wei per 1 USDC } async function calculateOptimalFee(relay, provider, transferContract, isMainnet = true) { // Step 1: Get network gas price const networkGasPrice = await provider.getGasPrice() console.log(' Network gas price:', ethers.utils.formatUnits(networkGasPrice, 'gwei'), 'gwei') console.log(' Relay min gas price:', ethers.utils.formatUnits(relay.minGasPrice, 'gwei'), 'gwei') // Step 2: Take max of network and relay minimum const baseGasPrice = networkGasPrice.gt(relay.minGasPrice) ? networkGasPrice : relay.minGasPrice // Step 3: Apply buffer (20% mainnet, 25% testnet) const bufferPercentage = isMainnet ? 120 : 125 const bufferedGasPrice = baseGasPrice.mul(bufferPercentage).div(100) console.log(' Buffered gas price:', ethers.utils.formatUnits(bufferedGasPrice, 'gwei'), 'gwei', `(${bufferPercentage}%)`) // Step 4: Get gas limit from transfer contract const gasLimit = await transferContract.getRequiredRelayGas(METHOD_SELECTOR_TRANSFER_WITH_PERMIT) console.log(' Gas limit:', gasLimit.toString()) // Step 5: Calculate base cost const baseCost = bufferedGasPrice.mul(gasLimit) // Step 6: Apply relay percentage fee const costWithPctFee = baseCost.mul(100 + relay.pctRelayFee).div(100) // Step 7: Add base relay fee const totalPOLCost = costWithPctFee.add(relay.baseRelayFee) console.log(' Total POL cost:', ethers.utils.formatEther(totalPOLCost), 'POL') console.log(' Relay fee:', `${relay.pctRelayFee}%`) // Step 8: Get real-time POL/USDC price from Uniswap const polPerUsdc = await getPolUsdcPrice(provider) console.log(' Uniswap rate:', ethers.utils.formatEther(polPerUsdc), 'POL per USDC') // Step 9: Convert POL fee to USDC // totalPOLCost (POL wei) / polPerUsdc (POL wei per USDC) = USDC base units const feeInUSDC = totalPOLCost.mul(1_000_000).div(polPerUsdc) console.log(' USDC fee:', ethers.utils.formatUnits(feeInUSDC, 6), 'USDC') return { usdcFee: feeInUSDC, gasPrice: bufferedGasPrice, gasLimit, polCost: totalPOLCost, } } async function findBestRelay(provider, transferContract) { console.log('\n🔍 Discovering relays...') const relays = await discoverRelays(provider) console.log(`Found ${relays.length} unique relay URLs`) console.log('\n🔬 Validating and calculating fees...\n') let bestRelay = null let lowestFee = ethers.constants.MaxUint256 for (const relay of relays) { const validRelay = await validateRelay(relay, provider) if (!validRelay) continue console.log(`📊 ${relay.url}`) try { const feeData = await calculateOptimalFee(validRelay, provider, transferContract) if (feeData.usdcFee.lt(lowestFee)) { lowestFee = feeData.usdcFee bestRelay = { ...validRelay, feeData } console.log(' ✅ New best relay!\n') } else { console.log(' ⚪ Not the cheapest\n') } } catch (error) { console.log(' ❌ Fee calculation failed:', error.message, '\n') } } if (!bestRelay) { throw new Error('No valid relays found') } return bestRelay } async function main() { console.log('🚀 Gasless USDC transfer with EIP-2612 Permit...\n') const provider = new ethers.providers.JsonRpcProvider(POLYGON_RPC_URL) const wallet = new ethers.Wallet(PRIVATE_KEY, provider) console.log('🔑 Sender:', wallet.address) console.log('📍 Receiver:', RECEIVER_ADDRESS) // Setup transfer contract const transferContract = new ethers.Contract(TRANSFER_CONTRACT_ADDRESS, TRANSFER_ABI, provider) // Find best relay with optimized fee const relay = await findBestRelay(provider, transferContract) console.log('\n✅ Selected relay:', relay.url) console.log(' Worker:', relay.relayWorkerAddress) console.log(' Optimized USDC fee:', ethers.utils.formatUnits(relay.feeData.usdcFee, 6), 'USDC') console.log(' Gas price:', ethers.utils.formatUnits(relay.feeData.gasPrice, 'gwei'), 'gwei') // Build transaction with calculated fee const usdc = new ethers.Contract(USDC_ADDRESS, USDC_ABI, provider) const usdcNonce = await usdc.nonces(wallet.address) const transferAmount = ethers.utils.parseUnits(TRANSFER_AMOUNT_USDC, 6) const feeAmount = relay.feeData.usdcFee // Use optimized fee const approvalAmount = transferAmount.add(feeAmount) console.log('\n💰 Transfer:', TRANSFER_AMOUNT_USDC, 'USDC') console.log('💸 Optimized fee:', ethers.utils.formatUnits(feeAmount, 6), 'USDC') console.log('✅ Total approval:', ethers.utils.formatUnits(approvalAmount, 6), 'USDC') // Sign USDC permit (EIP-2612) const deadline = ethers.constants.MaxUint256 const usdcDomain = { name: 'USD Coin', version: '2', chainId: 137, verifyingContract: USDC_ADDRESS, } const usdcTypes = { Permit: [ { name: 'owner', type: 'address' }, { name: 'spender', type: 'address' }, { name: 'value', type: 'uint256' }, { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint256' }, ], } const usdcMessage = { owner: wallet.address, spender: TRANSFER_CONTRACT_ADDRESS, value: approvalAmount, nonce: usdcNonce.toNumber(), deadline, } const approvalSignature = await wallet._signTypedData(usdcDomain, usdcTypes, usdcMessage) const { r: sigR, s: sigS, v: sigV } = ethers.utils.splitSignature(approvalSignature) // Build transfer calldata (5th param is approval value, not deadline) const transferCalldata = transferContract.interface.encodeFunctionData('transferWithPermit', [ USDC_ADDRESS, transferAmount, RECEIVER_ADDRESS, feeAmount, approvalAmount, sigR, sigS, sigV, ]) // Build relay request with optimized gas price const forwarderNonce = await transferContract.getNonce(wallet.address) const currentBlock = await provider.getBlockNumber() const validUntil = currentBlock + (2 * 60 * 2) // 2 hours const relayRequest = { request: { from: wallet.address, to: TRANSFER_CONTRACT_ADDRESS, value: '0', gas: relay.feeData.gasLimit.toString(), nonce: forwarderNonce.toString(), data: transferCalldata, validUntil: validUntil.toString(), }, relayData: { gasPrice: relay.feeData.gasPrice.toString(), pctRelayFee: relay.pctRelayFee.toString(), baseRelayFee: relay.baseRelayFee.toString(), relayWorker: relay.relayWorkerAddress, paymaster: TRANSFER_CONTRACT_ADDRESS, forwarder: TRANSFER_CONTRACT_ADDRESS, paymasterData: '0x', clientId: '1', }, } // Sign relay request const forwarderDomain = { name: 'Forwarder', version: '1', chainId: 137, verifyingContract: TRANSFER_CONTRACT_ADDRESS, } const typedData = new TypedRequestData( forwarderDomain.chainId, forwarderDomain.verifyingContract, relayRequest, ) const { EIP712Domain, ...cleanedTypes } = typedData.types const relaySignature = await wallet._signTypedData(typedData.domain, cleanedTypes, typedData.message) // Submit to relay console.log('\n📡 Submitting to relay...') const relayNonce = await provider.getTransactionCount(relay.relayWorkerAddress) const httpClient = new HttpClient(new HttpWrapper(), console) const relayResponse = await httpClient.relayTransaction(relay.url, { relayRequest, metadata: { signature: relaySignature, approvalData: '0x', relayHubAddress: RELAY_HUB_ADDRESS, relayMaxNonce: relayNonce + 3, }, }) const txHash = typeof relayResponse === 'string' ? relayResponse : relayResponse.signedTx || relayResponse.txHash console.log('\n✅ Gasless USDC transaction sent!') console.log('🔗 View:', `https://polygonscan.com/tx/${txHash}`) console.log('\n💡 Key differences from USDT:') console.log(' ✅ EIP-2612 Permit (not meta-transaction)') console.log(' ✅ Version-based domain separator') console.log(' ✅ transferWithPermit method (not transferWithApproval)') console.log(' ✅ Deadline parameter (not functionSignature)') } main().catch((error) => { console.error('\n❌ Error:', error.message) }) ``` #### package.json ```json { "name": "gasless-usdc-permit", "type": "module", "scripts": { "usdc": "node index.js" }, "dependencies": { "@opengsn/common": "^2.2.6", "ethers": "^5.7.2" } } ```