@menakalakshan/poly-pay
v1.6.0
Published
Polygon ERC-20 Payment Receiver SDK — self-custodial token monitoring with memo-encoded user identification, webhook ingestion, and MongoDB persistence
Maintainers
Readme
poly-pay
Polygon USDC Payment Receiver SDK — a self-contained Node.js library for accepting USDC payments on Polygon PoS with memo-encoded user identification, webhook-first event ingestion, MongoDB persistence, and automatic catch-up scanning.
Features
- Self-custodial — monitors your own receiver address, no custodial gateway
- Memo encoding — embeds user IDs in ERC-20 calldata for payment attribution
- Dual provider support — Alchemy (webhook + WebSocket) or Infura (WebSocket-only)
- Zero RPC polling — push-only ingestion;
getLogsonly runs at startup for catch-up - Idempotent — MongoDB unique index on
txHashprevents duplicate records - Catch-up scanner — recovers missed payments after any downtime, no matter how long
- TypeScript-first — full
.d.tsdeclarations, dual CJS + ESM output - Frontend helper — tree-shakeable
poly-pay/clientfor encoding transfers in the browser
Installation
npm install poly-payRequires Node.js >= 18 and MongoDB.
Quick Start
Server
import { PolyPay } from 'poly-pay';
import express from 'express';
const app = express();
app.use(express.json());
const pay = new PolyPay({
provider: 'alchemy',
receiverAddress: '0xYOUR_RECEIVER_WALLET',
mongoUri: process.env.MONGO_URI,
// Alchemy mode
alchemyApiKey: process.env.ALCHEMY_API_KEY,
alchemySigningKey:process.env.ALCHEMY_SIGNING_KEY,
// Optional user resolver — called only when a memo is present
resolveUser: async (userId) => {
return await User.findById(userId);
},
});
await pay.start();
// All transfers to your address are captured — with or without memo
pay.on('payment', ({ txHash, userId, memo, isMemoAvailable, amount, tokenSymbol, from }) => {
if (isMemoAvailable) {
console.log(`${userId} paid ${amount} ${tokenSymbol} (memo: ${memo})`);
} else {
console.log(`Anonymous payment: ${amount} ${tokenSymbol} from ${from}`);
}
});
// Mount webhook endpoint (Alchemy mode only)
app.use('/webhooks/poly-pay', pay.webhookRouter());
app.listen(3000);Frontend
import { encodeTransfer, USDC_POLYGON } from 'poly-pay/client';
const data = encodeTransfer({
to: '0xYOUR_RECEIVER_WALLET',
amount: '10.00',
memo: currentUser.id,
});
await window.ethereum.request({
method: 'eth_sendTransaction',
params: [{ to: USDC_POLYGON, data }],
});Custom ERC-20 Token
poly-pay works with any ERC-20 token on Polygon, not just USDC. Pass the contract address and decimals:
// Example: Accept DAI payments
const pay = new PolyPay({
provider: 'alchemy',
receiverAddress:'0xYOUR_WALLET',
mongoUri: process.env.MONGO_URI,
alchemyApiKey: process.env.ALCHEMY_API_KEY,
alchemySigningKey: process.env.ALCHEMY_SIGNING_KEY,
// Custom token config
tokenAddress: '0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063', // DAI on Polygon
tokenDecimals: 18,
tokenSymbol: 'DAI',
resolveUser: async (userId) => User.findById(userId),
});
pay.on('payment', ({ txHash, userId, amount, tokenSymbol }) => {
console.log(`${userId} paid ${amount} ${tokenSymbol}`);
// → "usr_123 paid 50.0 DAI"
});Frontend — pass decimals to match:
import { encodeTransfer } from 'poly-pay/client';
const DAI_POLYGON = '0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063';
const data = encodeTransfer({
to: '0xYOUR_WALLET',
amount: '50.00',
memo: currentUser.id,
decimals: 18, // DAI has 18 decimals
});
await window.ethereum.request({
method: 'eth_sendTransaction',
params: [{ to: DAI_POLYGON, data }],
});If you omit tokenAddress / tokenDecimals / tokenSymbol, the library defaults to native USDC (6 decimals) — fully backwards compatible.
Configuration
Constructor Options
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| provider | 'alchemy' \| 'infura' | Yes | — | Provider mode |
| receiverAddress | string | Yes | — | Your wallet address to monitor |
| mongoUri | string | Yes | — | MongoDB connection string |
| alchemyApiKey | string | Alchemy | — | Alchemy API key |
| alchemySigningKey | string | Alchemy | — | Webhook HMAC signing key |
| alchemyWebhookId | string | No | — | Alchemy webhook ID |
| infuraApiKey | string | Infura | — | Infura API key |
| tokenAddress | string | No | Native USDC | ERC-20 contract address to monitor |
| tokenDecimals | number | No | 6 | Token decimal places (e.g. 18 for DAI) |
| tokenSymbol | string | No | 'USDC' | Token symbol for labelling |
| network | 'polygon' \| 'polygon-amoy' | No | 'polygon' | Target network |
| memoEncoding | 'calldata' \| 'event' | No | 'calldata' | Memo encoding strategy |
| catchUpOnStart | boolean | No | true | Run catch-up scan on start |
| blockBatchSize | bigint | No | 2000n | Blocks per catch-up batch |
| startBlock | bigint | No | — | Starting block if no sync state exists |
| resolveUser | (id: string) => Promise<any> | Yes | — | User lookup callback |
Environment Variables
# Provider (pick one)
PROVIDER=alchemy
ALCHEMY_API_KEY=your_key
ALCHEMY_SIGNING_KEY=whsec_xxx
# PROVIDER=infura
# INFURA_API_KEY=your_key
# MongoDB
MONGO_URI=mongodb://localhost:27017/polypay
# Receiver
RECEIVER_ADDRESS=0xYOUR_WALLET
# Optional
NETWORK=polygon
CATCH_UP_ON_START=trueProvider Modes
Alchemy (recommended for production)
Uses Alchemy's address-activity webhook as the primary ingestion path. A WebSocket listener runs concurrently as a fallback. Alchemy retries webhook delivery up to 5 times if your server is temporarily down.
new PolyPay({
provider: 'alchemy',
alchemyApiKey: '...',
alchemySigningKey: '...',
// ...
});Requires webhook setup in the Alchemy dashboard — see Webhook Setup below.
Infura (simpler setup)
Uses a persistent WebSocket subscription as the sole live-ingestion path. No webhook endpoint is exposed, eliminating that attack surface. The catch-up scanner provides the same recovery guarantee.
new PolyPay({
provider: 'infura',
infuraApiKey: '...',
// ...
});API
PolyPay class
pay.start(): Promise<void>
Connects to MongoDB, starts the WebSocket listener, and runs the catch-up scan (if enabled).
pay.stop(): Promise<void>
Disconnects the WebSocket, closes the MongoDB connection.
pay.webhookRouter(): express.Router
Returns an Express Router to mount at your webhook endpoint. Alchemy mode only.
pay.catchUp(options?): Promise<void>
Manually trigger a catch-up scan.
await pay.catchUp({ fromBlock: 58_000_000n });pay.isRunning(): boolean
Returns whether the instance is currently active.
Events
pay.on('payment', ({
txHash, // transaction hash
userId, // string | null — decoded user ID from memo
memo, // string | null — raw memo value
isMemoAvailable, // boolean — whether the tx contained a memo
amount, // formatted token amount
tokenSymbol, // e.g. 'USDC', 'DAI'
from, // sender address
blockNumber, // block number
source, // 'webhook' | 'websocket' | 'catchup'
}) => {});
pay.on('error', (error) => {});
pay.on('reconnecting', (attempt) => {});
pay.on('started', () => {});
pay.on('stopped', () => {});encodeTransfer(params) (client)
import { encodeTransfer } from 'poly-pay/client';
const calldata = encodeTransfer({
to: '0xRECEIVER',
amount: '25.50', // USDC as string, or bigint in raw units
memo: 'user_123',
});MongoDB Schema
payments collection
| Field | Type | Description |
|---|---|---|
| txHash | String (unique) | Transaction hash — idempotency key |
| userId | String | null (indexed) | Decoded user ID from memo, or null |
| memo | String | null | Decoded memo value, or null |
| isMemoAvailable | Boolean | Whether the tx contained a memo |
| fromAddress | String | Sender wallet address |
| amount | String | Formatted token amount as decimal string |
| tokenAddress | String (indexed) | ERC-20 contract address |
| tokenSymbol | String | Token symbol (e.g. 'USDC', 'DAI') |
| tokenDecimals | Number | Token decimal places |
| blockNumber | Number | Block the tx was included in |
| network | String | 'polygon' or 'polygon-amoy' |
| status | String | 'confirmed' |
| memoRaw | String | Raw hex input data |
| source | String | 'webhook', 'websocket', or 'catchup' |
| createdAt | Date | When the record was created |
sync_state collection
Singleton document tracking the last processed block for the catch-up scanner.
Reliability
- Idempotency: Unique
txHashindex — safe for the same event to arrive from webhook, WebSocket, and catch-up simultaneously - Catch-up on startup: Scans from the last checkpoint to the current block in 2,000-block batches
- Checkpoint safety:
$maxoperator ensures the block pointer never moves backwards - Interrupted scan resume: Checkpoints after each batch, so a killed process resumes from where it left off
- WebSocket reconnection: Exponential backoff (1s → 2s → 4s → ... → 30s max)
Webhook Setup
For Alchemy mode:
- Go to the Alchemy dashboard, select your Polygon app
- Navigate to Notify → Create Webhook
- Choose Address Activity
- Enter your receiver address and webhook URL:
https://yourdomain.com/webhooks/poly-pay - Copy the Signing Key →
ALCHEMY_SIGNING_KEY - Copy the Webhook ID →
ALCHEMY_WEBHOOK_ID
USDC Contract Addresses
| Network | Address |
|---|---|
| Polygon mainnet (native USDC) | 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 |
| Polygon mainnet (bridged USDC.e) | 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 |
| Polygon Amoy testnet | 0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582 |
Development
# Install dependencies
npm install
# Type-check
npm run typecheck
# Run tests
npm test
# Build (CJS + ESM + .d.ts)
npm run build
# Start MongoDB for local dev
docker compose up -dLicense
MIT
