Gas fees have always been a UX nightmare for crypto payments. Users want to pay with USDC, but they need ETH first. This article explores a practical implementation using PayinGo — a payment service that leverages ERC-3009 signatures to make users' gas fees disappear.
The Technical Challenge
When a user pays with ERC-20 tokens, they must:
- Hold the native token (ETH/SOL) for gas
- Approve the token transfer
- Execute the transfer
This creates a chicken-and-egg problem: users can't spend their stablecoins without first acquiring gas tokens. Most solutions involve wrapping, meta-transactions, or relayers with complex smart contracts.
ERC-3009 offers a cleaner path.
How ERC-3009 Works
ERC-3009 (TransferWithAuthorization) allows token transfers via off-chain signatures. Instead of the user submitting a transaction, they sign an authorization message. Anyone holding that signature can execute the transfer on-chain.
function transferWithAuthorization(
address from,
address to,
uint256 value,
uint256 validAfter,
uint256 validBefore,
bytes32 nonce,
uint8 v, bytes32 r, bytes32 s
) external;
USDC on major chains (Base, Polygon, Ethereum) supports this natively. No wrapper contracts needed.
Architecture Overview
PayinGo implements this pattern with a facilitator model:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ Frontend │────▶│ PayinGo │────▶│ Facilitator │────▶│ USDC Contract│
│ (User signs│ │ API │ │ (pays gas) │ │ (on-chain) │
│ EIP-712) │ │ (validates) │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘ └──────────────┘
The user signs an EIP-712 typed message. The facilitator submits it on-chain and pays gas. Funds transfer directly from payer to recipient — the service never touches the money.
Implementation
Creating a Payment Session
curl -X POST https://go.payin.com/api/v1/checkout/create \
-H "Content-Type: application/json" \
-d '{
"amount": "50.00",
"currency": "USDC",
"recipientAddress": "0xMerchantWallet",
"network": "base",
"metadata": { "orderId": "ORD-123" },
"callbackUrl": "https://yoursite.com/webhook",
"callbackSecret": "your-secret"
}'
Response:
{
"success": true,
"payment": {
"id": "pay_abc123",
"status": "PENDING",
"paymentUrl": "https://go.payin.com/checkout/pay_abc123",
"expiresAt": "2024-01-15T10:30:00Z"
}
}
Frontend Signing (EVM)
The user's wallet signs an EIP-712 message:
const domain = {
name: 'USD Coin',
version: '2',
chainId: 8453, // Base
verifyingContract: USDC_ADDRESS
};
const types = {
TransferWithAuthorization: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'validAfter', type: 'uint256' },
{ name: 'validBefore', type: 'uint256' },
{ name: 'nonce', type: 'bytes32' }
]
};
const message = {
from: payerAddress,
to: merchantAddress,
value: parseUnits('50', 6), // USDC has 6 decimals
validAfter: 0,
validBefore: Math.floor(Date.now() / 1000) + 900, // 15 min
nonce: randomBytes(32)
};
const signature = await wallet.signTypedData(domain, types, message);
Webhook Handling
app.post('/webhook', (req, res) => {
const signature = req.headers['x-payingo-signature'];
const timestamp = req.headers['x-payingo-timestamp'];
const payload = JSON.stringify(req.body);
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${payload}`)
.digest('hex');
if (signature !== expected) {
return res.status(401).send('Invalid signature');
}
if (req.body.event === 'payment.completed') {
// Update order status
db.orders.update(req.body.metadata.orderId, {
status: 'paid',
txHash: req.body.txHash
});
}
res.status(200).send('OK');
});
Solana Implementation
Solana doesn't have ERC-3009, so the approach differs. The frontend builds a partial transaction with the user as signer but the facilitator as fee payer:
const transaction = new Transaction()
.add(ComputeBudgetProgram.setComputeUnitLimit({ units: 100000 }))
.add(createTransferCheckedInstruction(
payerTokenAccount,
USDC_MINT,
merchantTokenAccount,
payerPublicKey,
amount * 1e6,
6
));
transaction.feePayer = FACILITATOR_PUBKEY;
transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;
// User signs
const signed = await wallet.signTransaction(transaction);
// Send to API, facilitator adds fee payer signature and broadcasts
Network Support
| Network | Method | Settlement |
|---|---|---|
| Base | ERC-3009 | ~2s |
| Polygon | ERC-3009 | ~2s |
| Solana | Partial TX signing | ~2s |
Alternative: Payment Links
For simpler use cases (donations, invoices), there's a no-code option:
curl -X POST https://go.payin.com/api/v1/link/create \
-H "Content-Type: application/json" \
-d '{
"name": "Project Donation",
"evmAddress": "0xYourWallet",
"notifyEmail": "you@example.com"
}'
This returns a shareable URL. Users visit, connect wallet, enter amount, sign, done. No backend integration required.
Trade-offs
What works well:
- Zero gas for end users
- Direct P2P settlement (non-custodial)
- Simple API surface
- Fast finality (~2 seconds)
Limitations:
- USDC only (no ETH, BTC, or other tokens)
- Three networks currently (Base, Polygon, Solana)
- Dependent on facilitator availability
- Facilitator economics unclear long-term
When to Use This
This pattern fits well for:
- E-commerce checkout flows
- SaaS subscription payments
- Donations and tips
- Invoice payments
- Any scenario where UX matters more than token variety
If you need multi-token support or prefer fully decentralized infrastructure, other approaches (like Account Abstraction with ERC-4337) might be more appropriate.
References
Questions or alternative implementations? Drop a comment below.
Top comments (0)