diff --git a/.gitignore b/.gitignore index f59cc73..db350a7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ pnpm-debug.log* .env.production .DS_Store .idea +.worktrees/ diff --git a/README.md b/README.md index ad77f11..582630a 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ nimiq-tutorial/ │ │ ├── 1-connecting-to-network/ │ │ ├── 2-working-with-transactions/ │ │ ├── 3-staking-and-validators/ -│ │ └── 4-miscellaneous/ +│ │ └── 4-polygon/ │ └── templates/ # Code templates ├── public/ # Static assets ├── scripts/ # Build scripts diff --git a/eslint.config.js b/eslint.config.js index efcd7a1..40c71d2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,7 +4,7 @@ export default antfu({ formatters: true, unocss: true, astro: true, - ignores: ['./public/widget.css', './public/widget.js'], + ignores: ['./public/widget.css', './public/widget.js', './src/components/CustomTopBar.astro'], }, { rules: { 'no-console': 'off', diff --git a/src/components/CustomTopBar.astro b/src/components/CustomTopBar.astro index c24c425..1165689 100644 --- a/src/components/CustomTopBar.astro +++ b/src/components/CustomTopBar.astro @@ -44,13 +44,7 @@ const version = import.meta.env.PACKAGE_VERSION || '0.0.2' // Add version info to logo title on page load document.addEventListener('DOMContentLoaded', () => { // Find the logo element (assuming it's the first child of the logo slot) - const logoSlot = document.querySelector('nav [slot="logo"]') || - document.querySelector('nav a[href="/"]') || - document.querySelector('nav img') || - document.querySelector('nav svg') - - if (logoSlot) { - logoSlot.title = `Version ${version}` - } + const logoSlot = document.querySelector('nav [slot="logo"]') || document.querySelector('nav a[href="/"]') || document.querySelector('nav img') || document.querySelector('nav svg') + if (logoSlot) logoSlot.title = `Version ${version}` }) diff --git a/src/content/tutorial/5-polygon-basics/1-introduction/_files/index.js b/src/content/tutorial/5-polygon-basics/1-introduction/_files/index.js new file mode 100644 index 0000000..331b3a3 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/1-introduction/_files/index.js @@ -0,0 +1,173 @@ +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) diff --git a/src/content/tutorial/5-polygon-basics/1-introduction/_files/lib/wallet.js b/src/content/tutorial/5-polygon-basics/1-introduction/_files/lib/wallet.js new file mode 100644 index 0000000..5cc965c --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/1-introduction/_files/lib/wallet.js @@ -0,0 +1,44 @@ +import { ethers } from 'ethers' + +/** + * Creates a wallet from a password string. + * + * WHY USE THIS? + * - You can recreate the same wallet anytime by using the same password + * - No need to save private keys in files or worry about losing them + * - Just remember your password and you can access your wallet from any code + * + * HOW IT WORKS: + * - Your password is hashed (keccak256) to create a deterministic private key + * - Same password = same private key = same wallet address every time + * + * SECURITY NOTE: + * - This is PERFECT for testnets (learning, experiments) + * - For mainnet with real money, use a hardware wallet or proper mnemonic phrase + */ +export function createWalletFromPassword(password) { + // Hash the password to get a deterministic private key + const privateKey = ethers.utils.id(password) + const wallet = new ethers.Wallet(privateKey) + + return wallet +} + +/** + * Alternative wallet creation methods: + * + * 1. FROM MNEMONIC (12/24 word phrase): + * const mnemonic = "word1 word2 word3 ... word12" + * const wallet = ethers.Wallet.fromMnemonic(mnemonic) + * + * 2. FROM PRIVATE KEY (hex string): + * const privateKey = "0x1234567890abcdef..." + * const wallet = new ethers.Wallet(privateKey) + * + * 3. RANDOM WALLET (new every time): + * const wallet = ethers.Wallet.createRandom() + * console.log('Save this mnemonic:', wallet.mnemonic.phrase) + * + * 4. FROM PASSWORD (this method - best for tutorials): + * const wallet = createWalletFromPassword("my_unique_password_123") + */ diff --git a/src/content/tutorial/5-polygon-basics/1-introduction/_files/package.json b/src/content/tutorial/5-polygon-basics/1-introduction/_files/package.json new file mode 100644 index 0000000..e13dae1 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/1-introduction/_files/package.json @@ -0,0 +1 @@ +{ "name": "polygon-basics-demo", "type": "module", "version": "1.0.0", "scripts": { "demo": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } diff --git a/src/content/tutorial/5-polygon-basics/1-introduction/_solution/index.js b/src/content/tutorial/5-polygon-basics/1-introduction/_solution/index.js new file mode 100644 index 0000000..331b3a3 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/1-introduction/_solution/index.js @@ -0,0 +1,173 @@ +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) diff --git a/src/content/tutorial/5-polygon-basics/1-introduction/_solution/lib/wallet.js b/src/content/tutorial/5-polygon-basics/1-introduction/_solution/lib/wallet.js new file mode 100644 index 0000000..5cc965c --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/1-introduction/_solution/lib/wallet.js @@ -0,0 +1,44 @@ +import { ethers } from 'ethers' + +/** + * Creates a wallet from a password string. + * + * WHY USE THIS? + * - You can recreate the same wallet anytime by using the same password + * - No need to save private keys in files or worry about losing them + * - Just remember your password and you can access your wallet from any code + * + * HOW IT WORKS: + * - Your password is hashed (keccak256) to create a deterministic private key + * - Same password = same private key = same wallet address every time + * + * SECURITY NOTE: + * - This is PERFECT for testnets (learning, experiments) + * - For mainnet with real money, use a hardware wallet or proper mnemonic phrase + */ +export function createWalletFromPassword(password) { + // Hash the password to get a deterministic private key + const privateKey = ethers.utils.id(password) + const wallet = new ethers.Wallet(privateKey) + + return wallet +} + +/** + * Alternative wallet creation methods: + * + * 1. FROM MNEMONIC (12/24 word phrase): + * const mnemonic = "word1 word2 word3 ... word12" + * const wallet = ethers.Wallet.fromMnemonic(mnemonic) + * + * 2. FROM PRIVATE KEY (hex string): + * const privateKey = "0x1234567890abcdef..." + * const wallet = new ethers.Wallet(privateKey) + * + * 3. RANDOM WALLET (new every time): + * const wallet = ethers.Wallet.createRandom() + * console.log('Save this mnemonic:', wallet.mnemonic.phrase) + * + * 4. FROM PASSWORD (this method - best for tutorials): + * const wallet = createWalletFromPassword("my_unique_password_123") + */ diff --git a/src/content/tutorial/5-polygon-basics/1-introduction/_solution/package.json b/src/content/tutorial/5-polygon-basics/1-introduction/_solution/package.json new file mode 100644 index 0000000..e13dae1 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/1-introduction/_solution/package.json @@ -0,0 +1 @@ +{ "name": "polygon-basics-demo", "type": "module", "version": "1.0.0", "scripts": { "demo": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } diff --git a/src/content/tutorial/5-polygon-basics/1-introduction/content.md b/src/content/tutorial/5-polygon-basics/1-introduction/content.md new file mode 100644 index 0000000..cce0703 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/1-introduction/content.md @@ -0,0 +1,157 @@ +--- +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. diff --git a/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_files/.env.example b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_files/.env.example new file mode 100644 index 0000000..c46dca9 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_files/.env.example @@ -0,0 +1,2 @@ +# Save your private key here after Step 6 +PRIVATE_KEY= \ No newline at end of file diff --git a/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_files/index.js b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_files/index.js new file mode 100644 index 0000000..b3e9020 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_files/index.js @@ -0,0 +1,29 @@ +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() { + // TODO: Step 1 - Create wallet from your password (same as lesson 1) + // Hint: const wallet = createWalletFromPassword(WALLET_PASSWORD) + + // TODO: Step 2 - Connect wallet to Polygon Amoy provider + // Hint: const provider = new ethers.providers.JsonRpcProvider(RPC_URL) + // Hint: const connectedWallet = wallet.connect(provider) + + // TODO: Step 3 - Check POL balance + // Hint: const balance = await provider.getBalance(wallet.address) + // Hint: console.log('POL Balance:', ethers.utils.formatEther(balance)) + + // TODO: Step 4 - Check USDC balance + // Hint: const usdc = new ethers.Contract(USDC_ADDRESS, USDC_ABI, provider) + // Hint: const usdcBalance = await usdc.balanceOf(wallet.address) +} + +main().catch(console.error) diff --git a/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_files/lib/wallet.js b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_files/lib/wallet.js new file mode 100644 index 0000000..5cc965c --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_files/lib/wallet.js @@ -0,0 +1,44 @@ +import { ethers } from 'ethers' + +/** + * Creates a wallet from a password string. + * + * WHY USE THIS? + * - You can recreate the same wallet anytime by using the same password + * - No need to save private keys in files or worry about losing them + * - Just remember your password and you can access your wallet from any code + * + * HOW IT WORKS: + * - Your password is hashed (keccak256) to create a deterministic private key + * - Same password = same private key = same wallet address every time + * + * SECURITY NOTE: + * - This is PERFECT for testnets (learning, experiments) + * - For mainnet with real money, use a hardware wallet or proper mnemonic phrase + */ +export function createWalletFromPassword(password) { + // Hash the password to get a deterministic private key + const privateKey = ethers.utils.id(password) + const wallet = new ethers.Wallet(privateKey) + + return wallet +} + +/** + * Alternative wallet creation methods: + * + * 1. FROM MNEMONIC (12/24 word phrase): + * const mnemonic = "word1 word2 word3 ... word12" + * const wallet = ethers.Wallet.fromMnemonic(mnemonic) + * + * 2. FROM PRIVATE KEY (hex string): + * const privateKey = "0x1234567890abcdef..." + * const wallet = new ethers.Wallet(privateKey) + * + * 3. RANDOM WALLET (new every time): + * const wallet = ethers.Wallet.createRandom() + * console.log('Save this mnemonic:', wallet.mnemonic.phrase) + * + * 4. FROM PASSWORD (this method - best for tutorials): + * const wallet = createWalletFromPassword("my_unique_password_123") + */ diff --git a/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_files/package.json b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_files/package.json new file mode 100644 index 0000000..b5b73ff --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_files/package.json @@ -0,0 +1 @@ +{ "name": "polygon-wallet-setup", "type": "module", "version": "1.0.0", "scripts": { "dev": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } diff --git a/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_solution/.env.example b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_solution/.env.example new file mode 100644 index 0000000..c46dca9 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_solution/.env.example @@ -0,0 +1,2 @@ +# Save your private key here after Step 6 +PRIVATE_KEY= \ No newline at end of file diff --git a/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_solution/index.js b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_solution/index.js new file mode 100644 index 0000000..0a29164 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_solution/index.js @@ -0,0 +1,40 @@ +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) diff --git a/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_solution/lib/wallet.js b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_solution/lib/wallet.js new file mode 100644 index 0000000..5cc965c --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_solution/lib/wallet.js @@ -0,0 +1,44 @@ +import { ethers } from 'ethers' + +/** + * Creates a wallet from a password string. + * + * WHY USE THIS? + * - You can recreate the same wallet anytime by using the same password + * - No need to save private keys in files or worry about losing them + * - Just remember your password and you can access your wallet from any code + * + * HOW IT WORKS: + * - Your password is hashed (keccak256) to create a deterministic private key + * - Same password = same private key = same wallet address every time + * + * SECURITY NOTE: + * - This is PERFECT for testnets (learning, experiments) + * - For mainnet with real money, use a hardware wallet or proper mnemonic phrase + */ +export function createWalletFromPassword(password) { + // Hash the password to get a deterministic private key + const privateKey = ethers.utils.id(password) + const wallet = new ethers.Wallet(privateKey) + + return wallet +} + +/** + * Alternative wallet creation methods: + * + * 1. FROM MNEMONIC (12/24 word phrase): + * const mnemonic = "word1 word2 word3 ... word12" + * const wallet = ethers.Wallet.fromMnemonic(mnemonic) + * + * 2. FROM PRIVATE KEY (hex string): + * const privateKey = "0x1234567890abcdef..." + * const wallet = new ethers.Wallet(privateKey) + * + * 3. RANDOM WALLET (new every time): + * const wallet = ethers.Wallet.createRandom() + * console.log('Save this mnemonic:', wallet.mnemonic.phrase) + * + * 4. FROM PASSWORD (this method - best for tutorials): + * const wallet = createWalletFromPassword("my_unique_password_123") + */ diff --git a/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_solution/package.json b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_solution/package.json new file mode 100644 index 0000000..b5b73ff --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/_solution/package.json @@ -0,0 +1 @@ +{ "name": "polygon-wallet-setup", "type": "module", "version": "1.0.0", "scripts": { "dev": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } diff --git a/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/content.md b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/content.md new file mode 100644 index 0000000..7aff418 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/2-wallet-and-faucets/content.md @@ -0,0 +1,192 @@ +--- +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. diff --git a/src/content/tutorial/5-polygon-basics/3-sending-pol/_files/.env.example b/src/content/tutorial/5-polygon-basics/3-sending-pol/_files/.env.example new file mode 100644 index 0000000..be028d2 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/3-sending-pol/_files/.env.example @@ -0,0 +1,2 @@ +PRIVATE_KEY=your_private_key_from_lesson_1 +RECIPIENT=0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184 \ No newline at end of file diff --git a/src/content/tutorial/5-polygon-basics/3-sending-pol/_files/index.js b/src/content/tutorial/5-polygon-basics/3-sending-pol/_files/index.js new file mode 100644 index 0000000..73fbab1 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/3-sending-pol/_files/index.js @@ -0,0 +1,31 @@ +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() { + // TODO: Step 1 - Load wallet from your password + // Hint: const wallet = createWalletFromPassword(WALLET_PASSWORD).connect(provider) + + // TODO: Step 2 - Check balance + // Hint: const balance = await provider.getBalance(wallet.address) + + // TODO: Step 3 - Prepare transaction (RECIPIENT already defined above) + + // TODO: Step 4 - Send transaction + // Hint: const tx = await wallet.sendTransaction({ to: RECIPIENT, value: ... }) + + // TODO: Step 5 - Wait for confirmation + // Hint: const receipt = await tx.wait() + + // TODO: Step 6 - Check updated balances +} + +main().catch(console.error) diff --git a/src/content/tutorial/5-polygon-basics/3-sending-pol/_files/lib/wallet.js b/src/content/tutorial/5-polygon-basics/3-sending-pol/_files/lib/wallet.js new file mode 100644 index 0000000..5cc965c --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/3-sending-pol/_files/lib/wallet.js @@ -0,0 +1,44 @@ +import { ethers } from 'ethers' + +/** + * Creates a wallet from a password string. + * + * WHY USE THIS? + * - You can recreate the same wallet anytime by using the same password + * - No need to save private keys in files or worry about losing them + * - Just remember your password and you can access your wallet from any code + * + * HOW IT WORKS: + * - Your password is hashed (keccak256) to create a deterministic private key + * - Same password = same private key = same wallet address every time + * + * SECURITY NOTE: + * - This is PERFECT for testnets (learning, experiments) + * - For mainnet with real money, use a hardware wallet or proper mnemonic phrase + */ +export function createWalletFromPassword(password) { + // Hash the password to get a deterministic private key + const privateKey = ethers.utils.id(password) + const wallet = new ethers.Wallet(privateKey) + + return wallet +} + +/** + * Alternative wallet creation methods: + * + * 1. FROM MNEMONIC (12/24 word phrase): + * const mnemonic = "word1 word2 word3 ... word12" + * const wallet = ethers.Wallet.fromMnemonic(mnemonic) + * + * 2. FROM PRIVATE KEY (hex string): + * const privateKey = "0x1234567890abcdef..." + * const wallet = new ethers.Wallet(privateKey) + * + * 3. RANDOM WALLET (new every time): + * const wallet = ethers.Wallet.createRandom() + * console.log('Save this mnemonic:', wallet.mnemonic.phrase) + * + * 4. FROM PASSWORD (this method - best for tutorials): + * const wallet = createWalletFromPassword("my_unique_password_123") + */ diff --git a/src/content/tutorial/5-polygon-basics/3-sending-pol/_files/package.json b/src/content/tutorial/5-polygon-basics/3-sending-pol/_files/package.json new file mode 100644 index 0000000..ed011e2 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/3-sending-pol/_files/package.json @@ -0,0 +1 @@ +{ "name": "sending-pol", "type": "module", "version": "1.0.0", "scripts": { "send": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } diff --git a/src/content/tutorial/5-polygon-basics/3-sending-pol/_solution/.env.example b/src/content/tutorial/5-polygon-basics/3-sending-pol/_solution/.env.example new file mode 100644 index 0000000..be028d2 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/3-sending-pol/_solution/.env.example @@ -0,0 +1,2 @@ +PRIVATE_KEY=your_private_key_from_lesson_1 +RECIPIENT=0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184 \ No newline at end of file diff --git a/src/content/tutorial/5-polygon-basics/3-sending-pol/_solution/index.js b/src/content/tutorial/5-polygon-basics/3-sending-pol/_solution/index.js new file mode 100644 index 0000000..a417584 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/3-sending-pol/_solution/index.js @@ -0,0 +1,68 @@ +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) diff --git a/src/content/tutorial/5-polygon-basics/3-sending-pol/_solution/lib/wallet.js b/src/content/tutorial/5-polygon-basics/3-sending-pol/_solution/lib/wallet.js new file mode 100644 index 0000000..5cc965c --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/3-sending-pol/_solution/lib/wallet.js @@ -0,0 +1,44 @@ +import { ethers } from 'ethers' + +/** + * Creates a wallet from a password string. + * + * WHY USE THIS? + * - You can recreate the same wallet anytime by using the same password + * - No need to save private keys in files or worry about losing them + * - Just remember your password and you can access your wallet from any code + * + * HOW IT WORKS: + * - Your password is hashed (keccak256) to create a deterministic private key + * - Same password = same private key = same wallet address every time + * + * SECURITY NOTE: + * - This is PERFECT for testnets (learning, experiments) + * - For mainnet with real money, use a hardware wallet or proper mnemonic phrase + */ +export function createWalletFromPassword(password) { + // Hash the password to get a deterministic private key + const privateKey = ethers.utils.id(password) + const wallet = new ethers.Wallet(privateKey) + + return wallet +} + +/** + * Alternative wallet creation methods: + * + * 1. FROM MNEMONIC (12/24 word phrase): + * const mnemonic = "word1 word2 word3 ... word12" + * const wallet = ethers.Wallet.fromMnemonic(mnemonic) + * + * 2. FROM PRIVATE KEY (hex string): + * const privateKey = "0x1234567890abcdef..." + * const wallet = new ethers.Wallet(privateKey) + * + * 3. RANDOM WALLET (new every time): + * const wallet = ethers.Wallet.createRandom() + * console.log('Save this mnemonic:', wallet.mnemonic.phrase) + * + * 4. FROM PASSWORD (this method - best for tutorials): + * const wallet = createWalletFromPassword("my_unique_password_123") + */ diff --git a/src/content/tutorial/5-polygon-basics/3-sending-pol/_solution/package.json b/src/content/tutorial/5-polygon-basics/3-sending-pol/_solution/package.json new file mode 100644 index 0000000..ed011e2 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/3-sending-pol/_solution/package.json @@ -0,0 +1 @@ +{ "name": "sending-pol", "type": "module", "version": "1.0.0", "scripts": { "send": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } diff --git a/src/content/tutorial/5-polygon-basics/3-sending-pol/content.md b/src/content/tutorial/5-polygon-basics/3-sending-pol/content.md new file mode 100644 index 0000000..d3b6089 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/3-sending-pol/content.md @@ -0,0 +1,173 @@ +--- +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. diff --git a/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_files/.env.example b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_files/.env.example new file mode 100644 index 0000000..be028d2 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_files/.env.example @@ -0,0 +1,2 @@ +PRIVATE_KEY=your_private_key_from_lesson_1 +RECIPIENT=0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184 \ No newline at end of file diff --git a/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_files/index.js b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_files/index.js new file mode 100644 index 0000000..815efa3 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_files/index.js @@ -0,0 +1,31 @@ +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() { + // TODO: Step 1 - Load wallet from your password + // Hint: const wallet = createWalletFromPassword(WALLET_PASSWORD).connect(provider) + + // TODO: Step 2 - Connect to USDC contract with ABI + // Hint: const USDC_ABI = ['function transfer(...)', 'function balanceOf(...)', ...] + // Hint: const usdc = new ethers.Contract(USDC_ADDRESS, USDC_ABI, wallet) + + // TODO: Step 3 - Check USDC balance + // Hint: const balance = await usdc.balanceOf(wallet.address) + + // TODO: Step 4 - Send USDC (RECIPIENT already defined above) + // Hint: const tx = await usdc.transfer(RECIPIENT, amountInBaseUnits) + + // TODO: Step 5 - Verify balances changed +} + +main().catch(console.error) diff --git a/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_files/lib/wallet.js b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_files/lib/wallet.js new file mode 100644 index 0000000..5cc965c --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_files/lib/wallet.js @@ -0,0 +1,44 @@ +import { ethers } from 'ethers' + +/** + * Creates a wallet from a password string. + * + * WHY USE THIS? + * - You can recreate the same wallet anytime by using the same password + * - No need to save private keys in files or worry about losing them + * - Just remember your password and you can access your wallet from any code + * + * HOW IT WORKS: + * - Your password is hashed (keccak256) to create a deterministic private key + * - Same password = same private key = same wallet address every time + * + * SECURITY NOTE: + * - This is PERFECT for testnets (learning, experiments) + * - For mainnet with real money, use a hardware wallet or proper mnemonic phrase + */ +export function createWalletFromPassword(password) { + // Hash the password to get a deterministic private key + const privateKey = ethers.utils.id(password) + const wallet = new ethers.Wallet(privateKey) + + return wallet +} + +/** + * Alternative wallet creation methods: + * + * 1. FROM MNEMONIC (12/24 word phrase): + * const mnemonic = "word1 word2 word3 ... word12" + * const wallet = ethers.Wallet.fromMnemonic(mnemonic) + * + * 2. FROM PRIVATE KEY (hex string): + * const privateKey = "0x1234567890abcdef..." + * const wallet = new ethers.Wallet(privateKey) + * + * 3. RANDOM WALLET (new every time): + * const wallet = ethers.Wallet.createRandom() + * console.log('Save this mnemonic:', wallet.mnemonic.phrase) + * + * 4. FROM PASSWORD (this method - best for tutorials): + * const wallet = createWalletFromPassword("my_unique_password_123") + */ diff --git a/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_files/package.json b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_files/package.json new file mode 100644 index 0000000..db2f2f6 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_files/package.json @@ -0,0 +1 @@ +{ "name": "erc20-usdc", "type": "module", "version": "1.0.0", "scripts": { "send": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } diff --git a/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_solution/.env.example b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_solution/.env.example new file mode 100644 index 0000000..be028d2 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_solution/.env.example @@ -0,0 +1,2 @@ +PRIVATE_KEY=your_private_key_from_lesson_1 +RECIPIENT=0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184 \ No newline at end of file diff --git a/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_solution/index.js b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_solution/index.js new file mode 100644 index 0000000..fd1735e --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_solution/index.js @@ -0,0 +1,69 @@ +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) diff --git a/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_solution/lib/wallet.js b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_solution/lib/wallet.js new file mode 100644 index 0000000..5cc965c --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_solution/lib/wallet.js @@ -0,0 +1,44 @@ +import { ethers } from 'ethers' + +/** + * Creates a wallet from a password string. + * + * WHY USE THIS? + * - You can recreate the same wallet anytime by using the same password + * - No need to save private keys in files or worry about losing them + * - Just remember your password and you can access your wallet from any code + * + * HOW IT WORKS: + * - Your password is hashed (keccak256) to create a deterministic private key + * - Same password = same private key = same wallet address every time + * + * SECURITY NOTE: + * - This is PERFECT for testnets (learning, experiments) + * - For mainnet with real money, use a hardware wallet or proper mnemonic phrase + */ +export function createWalletFromPassword(password) { + // Hash the password to get a deterministic private key + const privateKey = ethers.utils.id(password) + const wallet = new ethers.Wallet(privateKey) + + return wallet +} + +/** + * Alternative wallet creation methods: + * + * 1. FROM MNEMONIC (12/24 word phrase): + * const mnemonic = "word1 word2 word3 ... word12" + * const wallet = ethers.Wallet.fromMnemonic(mnemonic) + * + * 2. FROM PRIVATE KEY (hex string): + * const privateKey = "0x1234567890abcdef..." + * const wallet = new ethers.Wallet(privateKey) + * + * 3. RANDOM WALLET (new every time): + * const wallet = ethers.Wallet.createRandom() + * console.log('Save this mnemonic:', wallet.mnemonic.phrase) + * + * 4. FROM PASSWORD (this method - best for tutorials): + * const wallet = createWalletFromPassword("my_unique_password_123") + */ diff --git a/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_solution/package.json b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_solution/package.json new file mode 100644 index 0000000..db2f2f6 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/_solution/package.json @@ -0,0 +1 @@ +{ "name": "erc20-usdc", "type": "module", "version": "1.0.0", "scripts": { "send": "node --watch index.js" }, "dependencies": { "ethers": "^5.7.2" } } diff --git a/src/content/tutorial/5-polygon-basics/4-erc20-usdc/content.md b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/content.md new file mode 100644 index 0000000..b5b6f19 --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/4-erc20-usdc/content.md @@ -0,0 +1,202 @@ +--- +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. diff --git a/src/content/tutorial/5-polygon-basics/meta.md b/src/content/tutorial/5-polygon-basics/meta.md new file mode 100644 index 0000000..192aaeb --- /dev/null +++ b/src/content/tutorial/5-polygon-basics/meta.md @@ -0,0 +1,12 @@ +--- +type: part +title: Polygon Basics +previews: false +prepareCommands: + - npm install +mainCommand: npm run dev +terminal: + open: true + activePanel: 0 + panels: ['output'] +--- diff --git a/src/content/tutorial/6-gasless-transfers/1-wallet-setup/_files/index.js b/src/content/tutorial/6-gasless-transfers/1-wallet-setup/_files/index.js new file mode 100644 index 0000000..3f50505 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/1-wallet-setup/_files/index.js @@ -0,0 +1,29 @@ +import { ethers } from 'ethers' + +async function main() { + console.log('🔐 Generating New Polygon Mainnet Wallet\n') + + // TODO: Create a random wallet using ethers.Wallet.createRandom() + + // TODO: Display the wallet address + + // TODO: Display the private key with a security warning + + // TODO: Display the 24-word 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) diff --git a/src/content/tutorial/6-gasless-transfers/1-wallet-setup/_files/package.json b/src/content/tutorial/6-gasless-transfers/1-wallet-setup/_files/package.json new file mode 100644 index 0000000..c5e060b --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/1-wallet-setup/_files/package.json @@ -0,0 +1,5 @@ +{ + "type": "module", + "scripts": { "generate": "node --watch index.js" }, + "dependencies": { "ethers": "^5.7.2" } +} diff --git a/src/content/tutorial/6-gasless-transfers/1-wallet-setup/_solution/index.js b/src/content/tutorial/6-gasless-transfers/1-wallet-setup/_solution/index.js new file mode 100644 index 0000000..ad88140 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/1-wallet-setup/_solution/index.js @@ -0,0 +1,35 @@ +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) diff --git a/src/content/tutorial/6-gasless-transfers/1-wallet-setup/_solution/package.json b/src/content/tutorial/6-gasless-transfers/1-wallet-setup/_solution/package.json new file mode 100644 index 0000000..c5e060b --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/1-wallet-setup/_solution/package.json @@ -0,0 +1,5 @@ +{ + "type": "module", + "scripts": { "generate": "node --watch index.js" }, + "dependencies": { "ethers": "^5.7.2" } +} diff --git a/src/content/tutorial/6-gasless-transfers/1-wallet-setup/content.md b/src/content/tutorial/6-gasless-transfers/1-wallet-setup/content.md new file mode 100644 index 0000000..5ba79df --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/1-wallet-setup/content.md @@ -0,0 +1,145 @@ +--- +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. diff --git a/src/content/tutorial/6-gasless-transfers/2-introduction/_files/index.js b/src/content/tutorial/6-gasless-transfers/2-introduction/_files/index.js new file mode 100644 index 0000000..4e29d47 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/2-introduction/_files/index.js @@ -0,0 +1,120 @@ +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) +}) diff --git a/src/content/tutorial/6-gasless-transfers/2-introduction/_files/lib/balances.js b/src/content/tutorial/6-gasless-transfers/2-introduction/_files/lib/balances.js new file mode 100644 index 0000000..ac5011b --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/2-introduction/_files/lib/balances.js @@ -0,0 +1,16 @@ +import { ethers } from 'ethers' +import { USDT_ABI, USDT_ADDRESS, USDT_DECIMALS } from './config.js' + +export async function checkBalances(provider, wallet) { + const polBalance = await provider.getBalance(wallet.address) + + const usdt = new ethers.Contract(USDT_ADDRESS, USDT_ABI, provider) + const usdtBalance = await usdt.balanceOf(wallet.address) + + return { + pol: polBalance, + usdt: usdtBalance, + polFormatted: ethers.utils.formatEther(polBalance), + usdtFormatted: ethers.utils.formatUnits(usdtBalance, USDT_DECIMALS), + } +} diff --git a/src/content/tutorial/6-gasless-transfers/2-introduction/_files/lib/config.js b/src/content/tutorial/6-gasless-transfers/2-introduction/_files/lib/config.js new file mode 100644 index 0000000..6105307 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/2-introduction/_files/lib/config.js @@ -0,0 +1,10 @@ +export const POLYGON_RPC_URL = 'https://polygon-rpc.com' + +export const USDT_ADDRESS = '0xc2132D05D31c914a87C6611C10748AEb04B58e8F' +export const USDT_DECIMALS = 6 + +export const USDT_ABI = [ + 'function balanceOf(address) view returns (uint256)', +] + +export const TRANSFER_AMOUNT_USDT = '0.01' diff --git a/src/content/tutorial/6-gasless-transfers/2-introduction/_files/package.json b/src/content/tutorial/6-gasless-transfers/2-introduction/_files/package.json new file mode 100644 index 0000000..569b35a --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/2-introduction/_files/package.json @@ -0,0 +1 @@ +{ "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" } } diff --git a/src/content/tutorial/6-gasless-transfers/2-introduction/_solution/index.js b/src/content/tutorial/6-gasless-transfers/2-introduction/_solution/index.js new file mode 100644 index 0000000..4e29d47 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/2-introduction/_solution/index.js @@ -0,0 +1,120 @@ +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) +}) diff --git a/src/content/tutorial/6-gasless-transfers/2-introduction/_solution/lib/balances.js b/src/content/tutorial/6-gasless-transfers/2-introduction/_solution/lib/balances.js new file mode 100644 index 0000000..ac5011b --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/2-introduction/_solution/lib/balances.js @@ -0,0 +1,16 @@ +import { ethers } from 'ethers' +import { USDT_ABI, USDT_ADDRESS, USDT_DECIMALS } from './config.js' + +export async function checkBalances(provider, wallet) { + const polBalance = await provider.getBalance(wallet.address) + + const usdt = new ethers.Contract(USDT_ADDRESS, USDT_ABI, provider) + const usdtBalance = await usdt.balanceOf(wallet.address) + + return { + pol: polBalance, + usdt: usdtBalance, + polFormatted: ethers.utils.formatEther(polBalance), + usdtFormatted: ethers.utils.formatUnits(usdtBalance, USDT_DECIMALS), + } +} diff --git a/src/content/tutorial/6-gasless-transfers/2-introduction/_solution/lib/config.js b/src/content/tutorial/6-gasless-transfers/2-introduction/_solution/lib/config.js new file mode 100644 index 0000000..6105307 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/2-introduction/_solution/lib/config.js @@ -0,0 +1,10 @@ +export const POLYGON_RPC_URL = 'https://polygon-rpc.com' + +export const USDT_ADDRESS = '0xc2132D05D31c914a87C6611C10748AEb04B58e8F' +export const USDT_DECIMALS = 6 + +export const USDT_ABI = [ + 'function balanceOf(address) view returns (uint256)', +] + +export const TRANSFER_AMOUNT_USDT = '0.01' diff --git a/src/content/tutorial/6-gasless-transfers/2-introduction/_solution/package.json b/src/content/tutorial/6-gasless-transfers/2-introduction/_solution/package.json new file mode 100644 index 0000000..569b35a --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/2-introduction/_solution/package.json @@ -0,0 +1 @@ +{ "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" } } diff --git a/src/content/tutorial/6-gasless-transfers/2-introduction/content.md b/src/content/tutorial/6-gasless-transfers/2-introduction/content.md new file mode 100644 index 0000000..e186f43 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/2-introduction/content.md @@ -0,0 +1,228 @@ +--- +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. diff --git a/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/.env.example b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/.env.example new file mode 100644 index 0000000..9c23925 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/.env.example @@ -0,0 +1,4 @@ +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 diff --git a/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/index.js b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/index.js new file mode 100644 index 0000000..2fdad4c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/index.js @@ -0,0 +1,56 @@ +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' + + // STEP 1: create a provider connected to Polygon mainnet + const provider = undefined // TODO + + // STEP 2: load the funded wallet and connect it to the provider + const wallet = undefined // TODO + + console.log('Sender:', wallet.address) + console.log('Receiver:', RECEIVER_ADDRESS) + console.log('\n--- Balances before transfer ---') + + // STEP 3: Check balances using the helper function + const balancesBefore = undefined // TODO: call 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') + + // STEP 4: parse the USDT amount and send the transfer transaction + const amountBaseUnits = undefined // TODO parseUnits + + console.log(`\nSending ${TRANSFER_AMOUNT_USDT} USDT to ${RECEIVER_ADDRESS}...`) + + // STEP 5: create USDT contract instance and transfer + const usdt = undefined // TODO: new ethers.Contract(POLYGON_CONFIG.usdtTokenAddress, USDT_ABI, wallet) + const transferTx = undefined // TODO call usdt.transfer + + console.log('Submitted tx:', `${POLYGON_CONFIG.explorerBaseUrl}${transferTx.hash}`) + const receipt = await transferTx.wait() + console.log('Mined in block', receipt.blockNumber) + + // STEP 6: re-check balances to confirm the transfer + const balancesAfter = undefined // TODO: call 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) +}) diff --git a/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/lib/balances.js b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/lib/balances.js new file mode 100644 index 0000000..d7a7ca6 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/lib/balances.js @@ -0,0 +1,30 @@ +import { ethers } from 'ethers' +import { POLYGON_CONFIG, USDT_ABI, USDT_DECIMALS } from './constants.js' + +export async function checkBalances(provider, wallet, receiverAddress) { + const polBalance = await provider.getBalance(wallet.address) + + const usdt = new ethers.Contract( + POLYGON_CONFIG.usdtTokenAddress, + USDT_ABI, + wallet, + ) + + const [senderUsdt, receiverUsdt] = await Promise.all([ + usdt.balanceOf(wallet.address), + usdt.balanceOf(receiverAddress), + ]) + + return { + sender: { + pol: polBalance, + usdt: senderUsdt, + polFormatted: ethers.utils.formatEther(polBalance), + usdtFormatted: ethers.utils.formatUnits(senderUsdt, USDT_DECIMALS), + }, + receiver: { + usdt: receiverUsdt, + usdtFormatted: ethers.utils.formatUnits(receiverUsdt, USDT_DECIMALS), + }, + } +} diff --git a/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/lib/constants.js b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/lib/constants.js new file mode 100644 index 0000000..00b398e --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/lib/constants.js @@ -0,0 +1,15 @@ +export const USDT_DECIMALS = 6 + +export const POLYGON_CONFIG = { + usdtTokenAddress: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', + explorerBaseUrl: 'https://polygonscan.com/tx/', + nativeSymbol: 'MATIC', +} + +export const USDT_ABI = [ + 'function name() view returns (string)', + 'function symbol() view returns (string)', + 'function decimals() view returns (uint8)', + 'function balanceOf(address owner) view returns (uint256)', + 'function transfer(address to, uint256 amount) returns (bool)', +] diff --git a/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/package.json b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/package.json new file mode 100644 index 0000000..aa68f2c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_files/package.json @@ -0,0 +1,12 @@ +{ + "name": "polygon-usdc-mainnet-lab", + "type": "module", + "version": "0.1.0", + "private": true, + "scripts": { + "send": "node --watch index.js" + }, + "dependencies": { + "ethers": "^5.7.2" + } +} diff --git a/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/.env.example b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/.env.example new file mode 100644 index 0000000..9c23925 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/.env.example @@ -0,0 +1,4 @@ +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 diff --git a/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/index.js b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/index.js new file mode 100644 index 0000000..2df9648 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/index.js @@ -0,0 +1,49 @@ +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) +}) diff --git a/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/lib/balances.js b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/lib/balances.js new file mode 100644 index 0000000..d7a7ca6 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/lib/balances.js @@ -0,0 +1,30 @@ +import { ethers } from 'ethers' +import { POLYGON_CONFIG, USDT_ABI, USDT_DECIMALS } from './constants.js' + +export async function checkBalances(provider, wallet, receiverAddress) { + const polBalance = await provider.getBalance(wallet.address) + + const usdt = new ethers.Contract( + POLYGON_CONFIG.usdtTokenAddress, + USDT_ABI, + wallet, + ) + + const [senderUsdt, receiverUsdt] = await Promise.all([ + usdt.balanceOf(wallet.address), + usdt.balanceOf(receiverAddress), + ]) + + return { + sender: { + pol: polBalance, + usdt: senderUsdt, + polFormatted: ethers.utils.formatEther(polBalance), + usdtFormatted: ethers.utils.formatUnits(senderUsdt, USDT_DECIMALS), + }, + receiver: { + usdt: receiverUsdt, + usdtFormatted: ethers.utils.formatUnits(receiverUsdt, USDT_DECIMALS), + }, + } +} diff --git a/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/lib/constants.js b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/lib/constants.js new file mode 100644 index 0000000..00b398e --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/lib/constants.js @@ -0,0 +1,15 @@ +export const USDT_DECIMALS = 6 + +export const POLYGON_CONFIG = { + usdtTokenAddress: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', + explorerBaseUrl: 'https://polygonscan.com/tx/', + nativeSymbol: 'MATIC', +} + +export const USDT_ABI = [ + 'function name() view returns (string)', + 'function symbol() view returns (string)', + 'function decimals() view returns (uint8)', + 'function balanceOf(address owner) view returns (uint256)', + 'function transfer(address to, uint256 amount) returns (bool)', +] diff --git a/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/package.json b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/package.json new file mode 100644 index 0000000..aa68f2c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/_solution/package.json @@ -0,0 +1,12 @@ +{ + "name": "polygon-usdc-mainnet-lab", + "type": "module", + "version": "0.1.0", + "private": true, + "scripts": { + "send": "node --watch index.js" + }, + "dependencies": { + "ethers": "^5.7.2" + } +} diff --git a/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/content.md b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/content.md new file mode 100644 index 0000000..a0c752c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/3-gasful-baseline/content.md @@ -0,0 +1,87 @@ +--- +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. diff --git a/src/content/tutorial/6-gasless-transfers/4-static-relay/_files/.env.example b/src/content/tutorial/6-gasless-transfers/4-static-relay/_files/.env.example new file mode 100644 index 0000000..2df88f4 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/4-static-relay/_files/.env.example @@ -0,0 +1,5 @@ +POLYGON_RPC_URL=https://polygon-rpc.com +SPONSOR_PRIVATE_KEY= +RECEIVER_ADDRESS=0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184 +TRANSFER_AMOUNT_USDT=0.01 +RELAY_URL=https://polygon-relay.fastspot.io \ No newline at end of file diff --git a/src/content/tutorial/6-gasless-transfers/4-static-relay/_files/index.js b/src/content/tutorial/6-gasless-transfers/4-static-relay/_files/index.js new file mode 100644 index 0000000..76c923c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/4-static-relay/_files/index.js @@ -0,0 +1,11 @@ +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' + +// TODO: Add the contract addresses +// TODO: Implement USDT approval signature +// TODO: Build and sign relay request +// TODO: Submit to static relay diff --git a/src/content/tutorial/6-gasless-transfers/4-static-relay/_files/package.json b/src/content/tutorial/6-gasless-transfers/4-static-relay/_files/package.json new file mode 100644 index 0000000..eb0292c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/4-static-relay/_files/package.json @@ -0,0 +1 @@ +{ "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" } } diff --git a/src/content/tutorial/6-gasless-transfers/4-static-relay/_solution/index.js b/src/content/tutorial/6-gasless-transfers/4-static-relay/_solution/index.js new file mode 100644 index 0000000..d18438b --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/4-static-relay/_solution/index.js @@ -0,0 +1,198 @@ +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) +}) diff --git a/src/content/tutorial/6-gasless-transfers/4-static-relay/_solution/package.json b/src/content/tutorial/6-gasless-transfers/4-static-relay/_solution/package.json new file mode 100644 index 0000000..eb0292c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/4-static-relay/_solution/package.json @@ -0,0 +1 @@ +{ "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" } } diff --git a/src/content/tutorial/6-gasless-transfers/4-static-relay/content.md b/src/content/tutorial/6-gasless-transfers/4-static-relay/content.md new file mode 100644 index 0000000..85c289c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/4-static-relay/content.md @@ -0,0 +1,282 @@ +--- +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. diff --git a/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_files/.env.example b/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_files/.env.example new file mode 100644 index 0000000..a1ca84c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_files/.env.example @@ -0,0 +1,4 @@ +POLYGON_RPC_URL=https://polygon-rpc.com +SPONSOR_PRIVATE_KEY= +RECEIVER_ADDRESS=0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184 +TRANSFER_AMOUNT_USDT=0.01 diff --git a/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_files/index.js b/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_files/index.js new file mode 100644 index 0000000..2013682 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_files/index.js @@ -0,0 +1,17 @@ +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' + +// TODO: Query RelayHub for relay registrations +// TODO: Validate each relay (version, network, balance, fees) +// TODO: Select the first valid relay +// TODO: Use it for gasless transfer + +async function main() { + console.log('TODO: Implement relay discovery') +} + +main().catch(console.error) diff --git a/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_files/package.json b/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_files/package.json new file mode 100644 index 0000000..a55862c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_files/package.json @@ -0,0 +1 @@ +{ "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" } } diff --git a/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_solution/index.js b/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_solution/index.js new file mode 100644 index 0000000..4402645 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_solution/index.js @@ -0,0 +1,295 @@ +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) +}) diff --git a/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_solution/package.json b/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_solution/package.json new file mode 100644 index 0000000..a55862c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/5-relay-discovery/_solution/package.json @@ -0,0 +1 @@ +{ "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" } } diff --git a/src/content/tutorial/6-gasless-transfers/5-relay-discovery/content.md b/src/content/tutorial/6-gasless-transfers/5-relay-discovery/content.md new file mode 100644 index 0000000..abe1198 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/5-relay-discovery/content.md @@ -0,0 +1,180 @@ +--- +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. diff --git a/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_files/.env.example b/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_files/.env.example new file mode 100644 index 0000000..a1ca84c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_files/.env.example @@ -0,0 +1,4 @@ +POLYGON_RPC_URL=https://polygon-rpc.com +SPONSOR_PRIVATE_KEY= +RECEIVER_ADDRESS=0xA3E49ef624bEaC43D29Af86bBFdE975Abaa0E184 +TRANSFER_AMOUNT_USDT=0.01 diff --git a/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_files/index.js b/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_files/index.js new file mode 100644 index 0000000..bd66035 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_files/index.js @@ -0,0 +1,21 @@ +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' + +// TODO: Implement relay discovery +// TODO: Calculate optimized fees for each relay: +// - Get network gas price and relay minimum +// - Apply safety buffers (20% mainnet, 25% testnet) +// - Calculate POL cost with relay percentage fee +// - Convert to USDT with safety margin +// TODO: Compare relays and select the cheapest +// TODO: Use optimized fee for gasless transfer + +async function main() { + console.log('TODO: Implement optimized fee calculation') +} + +main().catch(console.error) diff --git a/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_files/package.json b/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_files/package.json new file mode 100644 index 0000000..a55862c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_files/package.json @@ -0,0 +1 @@ +{ "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" } } diff --git a/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_solution/index.js b/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_solution/index.js new file mode 100644 index 0000000..36ac70f --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_solution/index.js @@ -0,0 +1,379 @@ +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) +}) diff --git a/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_solution/package.json b/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_solution/package.json new file mode 100644 index 0000000..a55862c --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/6-optimized-fees/_solution/package.json @@ -0,0 +1 @@ +{ "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" } } diff --git a/src/content/tutorial/6-gasless-transfers/6-optimized-fees/content.md b/src/content/tutorial/6-gasless-transfers/6-optimized-fees/content.md new file mode 100644 index 0000000..9f2246b --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/6-optimized-fees/content.md @@ -0,0 +1,297 @@ +--- +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. diff --git a/src/content/tutorial/6-gasless-transfers/7-usdc-permit/_files/index.js b/src/content/tutorial/6-gasless-transfers/7-usdc-permit/_files/index.js new file mode 100644 index 0000000..7d314b6 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/7-usdc-permit/_files/index.js @@ -0,0 +1,286 @@ +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' + +// TODO: Add Uniswap V3 addresses +// - UNISWAP_QUOTER: 0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6 +// - WMATIC_ADDRESS: 0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270 +// - USDC_WMATIC_POOL: 0xA374094527e1673A86dE625aa59517c5dE346d32 + +// TODO: Add method selector for transferWithPermit (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() + + 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 + + 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) { + // TODO: Query Uniswap V3 pool for USDC/WMATIC price + // 1. Create pool contract with USDC_WMATIC_POOL + // 2. Create quoter contract with UNISWAP_QUOTER + // 3. Get pool fee + // 4. Call quoter.callStatic.quoteExactInputSingle with: + // - tokenIn: USDC_ADDRESS + // - tokenOut: WMATIC_ADDRESS + // - fee: pool fee + // - amountIn: 1 USDC (1e6) + // - sqrtPriceLimitX96: 0 + // 5. Return POL amount +} + +async function calculateOptimalFee(relay, provider, transferContract, isMainnet = true) { + 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') + + const baseGasPrice = networkGasPrice.gt(relay.minGasPrice) ? networkGasPrice : relay.minGasPrice + const bufferPercentage = isMainnet ? 120 : 125 + const bufferedGasPrice = baseGasPrice.mul(bufferPercentage).div(100) + + console.log(' Buffered gas price:', ethers.utils.formatUnits(bufferedGasPrice, 'gwei'), 'gwei', `(${bufferPercentage}%)`) + + // TODO: Get gas limit using METHOD_SELECTOR_TRANSFER_WITH_PERMIT + // const gasLimit = await transferContract.getRequiredRelayGas(METHOD_SELECTOR_TRANSFER_WITH_PERMIT) + + // console.log(' Gas limit:', gasLimit.toString()) + + // const baseCost = bufferedGasPrice.mul(gasLimit) + // const costWithPctFee = baseCost.mul(100 + relay.pctRelayFee).div(100) + // const totalPOLCost = costWithPctFee.add(relay.baseRelayFee) + + // console.log(' Total POL cost:', ethers.utils.formatEther(totalPOLCost), 'POL') + // console.log(' Relay fee:', `${relay.pctRelayFee}%`) + + // TODO: Get POL/USDC price from Uniswap + // const polPerUsdc = await getPolUsdcPrice(provider) + + // console.log(' Uniswap rate:', ethers.utils.formatEther(polPerUsdc), 'POL per USDC') + + // TODO: Convert POL fee to USDC + // const feeInUSDC = totalPOLCost.mul(1_000_000).div(polPerUsdc) + + // console.log(' USDC fee:', ethers.utils.formatUnits(feeInUSDC, 6), 'USDC') + + // TODO: Return calculated values + // 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) + + const transferContract = new ethers.Contract(TRANSFER_CONTRACT_ADDRESS, TRANSFER_ABI, provider) + + 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') + + 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 + 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') + + // TODO: Sign EIP-2612 Permit + // Domain: + // name: 'USD Coin' + // version: '2' + // chainId: 137 + // verifyingContract: USDC_ADDRESS + // + // Types: + // Permit: [ + // { name: 'owner', type: 'address' }, + // { name: 'spender', type: 'address' }, + // { name: 'value', type: 'uint256' }, + // { name: 'nonce', type: 'uint256' }, + // { name: 'deadline', type: 'uint256' }, + // ] + // + // Message: + // owner: wallet.address + // spender: TRANSFER_CONTRACT_ADDRESS + // value: approvalAmount + // nonce: usdcNonce.toNumber() + // deadline: ethers.constants.MaxUint256 + + // TODO: Build transfer calldata with transferWithPermit + // Parameters: [token, amount, target, fee, approvalAmount, r, s, v] + + // TODO: Build relay request (same as Lesson 6) + + // TODO: Sign relay request (same as Lesson 6) + + // TODO: Submit to relay (same as Lesson 6) + // const 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) +}) diff --git a/src/content/tutorial/6-gasless-transfers/7-usdc-permit/_files/package.json b/src/content/tutorial/6-gasless-transfers/7-usdc-permit/_files/package.json new file mode 100644 index 0000000..e00cb64 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/7-usdc-permit/_files/package.json @@ -0,0 +1,11 @@ +{ + "name": "gasless-usdc-permit", + "type": "module", + "scripts": { + "usdc": "node index.js" + }, + "dependencies": { + "@opengsn/common": "^2.2.6", + "ethers": "^5.7.2" + } +} diff --git a/src/content/tutorial/6-gasless-transfers/7-usdc-permit/_solution/index.js b/src/content/tutorial/6-gasless-transfers/7-usdc-permit/_solution/index.js new file mode 100644 index 0000000..6fe41a6 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/7-usdc-permit/_solution/index.js @@ -0,0 +1,379 @@ +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) +}) diff --git a/src/content/tutorial/6-gasless-transfers/7-usdc-permit/_solution/package.json b/src/content/tutorial/6-gasless-transfers/7-usdc-permit/_solution/package.json new file mode 100644 index 0000000..e00cb64 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/7-usdc-permit/_solution/package.json @@ -0,0 +1,11 @@ +{ + "name": "gasless-usdc-permit", + "type": "module", + "scripts": { + "usdc": "node index.js" + }, + "dependencies": { + "@opengsn/common": "^2.2.6", + "ethers": "^5.7.2" + } +} diff --git a/src/content/tutorial/6-gasless-transfers/7-usdc-permit/content.md b/src/content/tutorial/6-gasless-transfers/7-usdc-permit/content.md new file mode 100644 index 0000000..8965e35 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/7-usdc-permit/content.md @@ -0,0 +1,292 @@ +--- +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. diff --git a/src/content/tutorial/6-gasless-transfers/meta.md b/src/content/tutorial/6-gasless-transfers/meta.md new file mode 100644 index 0000000..0535628 --- /dev/null +++ b/src/content/tutorial/6-gasless-transfers/meta.md @@ -0,0 +1,12 @@ +--- +type: part +title: Gasless Transfers +previews: false +prepareCommands: + - npm install +mainCommand: npm run send +terminal: + open: true + activePanel: 0 + panels: ['output'] +---