A non-custodial stablecoin payment gateway: an on-chain invoice contract (Solidity) plus an off-chain service (Node.js + Viem) that creates invoices, watches Paid events, and fires HMAC-signed webhooks to merchants.
contracts/InvoiceVault.sol ← 158 lines, 100% line coverage
test/InvoiceVault.test.ts ← 24 cases (admin, lifecycle, multi-token, expiry, fuzz pattern)
api/ ← Express + better-sqlite3 + Viem listener
scripts/deploy.ts ← Multi-chain deploy + Basescan verify
Most "accept USDT payments" SaaS (NowPayments, BitPay, Coinbase Commerce) is custodial — they hold the funds and you trust them not to disappear. For Web3-native merchants that's a regression. This gateway is non-custodial: the vault transfers funds directly from payer to payee in the same transaction, and never holds anything itself.
| Custodial (NowPayments, BitPay) | This gateway | |
|---|---|---|
| Who holds funds in transit? | The processor | Nobody — atomic transfer |
| Counterparty risk | Yes (processor exit, freeze, KYC) | None |
| Refund flow | Through the processor | Direct on-chain |
| Self-hostable | No | Yes — one contract + one Node service |
| Multi-chain | Yes | Yes (any EVM chain) |
| Multi-token | Yes | Yes (allowlist controlled) |
┌──────────────┐ POST /invoices ┌──────────────────┐ createInvoice(...) ┌─────────────────┐
│ Merchant │ ───────────────────▶ │ Gateway API │ ───────────────────────▶ │ InvoiceVault.sol│
│ (backend) │ │ (Node + Viem) │ │ (EVM chain) │
└──────────────┘ └──────────────────┘ └─────────────────┘
▲ ▲ │
│ │ watchContractEvent('Paid') │
│ webhook (HMAC-SHA256) │ │ emits Paid
└───────────────────────────────────────┴────────────────────────────────────────────┘
▲
┌──────────────┐ pay(id) (with prior token.approve) │
│ Payer │ ───────────────────────────────────────────────────────────────────────────┘
│ (wallet) │
└──────────────┘
The vault never holds funds. pay() does safeTransferFrom(payer → payee, amount) in one tx. If the off-chain webhook fails, the on-chain Paid event is still the source of truth.
contract InvoiceVault is Ownable, ReentrancyGuard, Pausable {
struct Invoice {
address payee; address token; uint96 expectedAmount;
uint32 createdAt; uint32 expiresAt; bool paid;
}
mapping(bytes32 => Invoice) public invoices;
mapping(address => bool) public tokenAllowed;
}| Function | Caller | Purpose |
|---|---|---|
setTokenAllowed(token, allowed) / setTokensAllowed(...) |
owner | Manage the stablecoin allowlist. |
createInvoice(id, payee, token, amount, ttl) |
anyone | Register an invoice. id is caller-supplied (typically keccak256(merchant, order, salt)). |
pay(id) |
payer | Settle. Requires prior token.approve(vault, amount). |
getInvoice(id) / isOpen(id) |
view | Off-chain reads. |
pause() / unpause() |
owner | Halt new invoices and settlements. |
cp .env.example .env # fill in PRIVATE_KEY, RPC, VAULT_ADDRESS
npx tsx api/index.ts{
"payee": "0xMerchantAddress",
"token": "0xUSDCorUSDT",
"amount": "10000000", // base units (USDC has 6 decimals → 10 USDC)
"ttlSeconds": 1800,
"webhookUrl": "https://shop.example/webhooks/gateway",
"metadata": { "orderId": 9123, "customer": "alice" }
}Response:
{
"id": "0x4f...", // bytes32 — also the on-chain id
"payee": "0xMerchantAddress",
"token": "0xUSDCorUSDT",
"amount": "10000000",
"expiresAt": 1747890123,
"payIntent": { "contract": "InvoiceVault", "method": "pay", "args": ["0x4f..."] }
}Returns the current DB record, including status (created / paid / expired), paidTxHash, and webhook delivery status.
When the listener sees a Paid event whose id matches an invoice in the DB, it fires:
POST {invoice.webhookUrl}
Content-Type: application/json
X-Gateway-Event: invoice.paid
X-Gateway-Signature: <hex HMAC-SHA256(body, WEBHOOK_SECRET)>
{
"event": "invoice.paid",
"invoice": {
"id": "0x4f...",
"payee": "0x...",
"token": "0x...",
"amount": "10000000",
"paidTxHash": "0xabc...",
"paidAt": 1747890200,
"metadata": { "orderId": 9123 }
}
}Merchants verify the signature with the shared WEBHOOK_SECRET before trusting the payload.
git clone https://github.com/0xPenwright/crypto-payment-gateway.git
cd crypto-payment-gateway
npm install --legacy-peer-deps
npx hardhat test
npx hardhat coverageLast coverage on this commit:
File | % Stmts | % Branch | % Funcs | % Lines
InvoiceVault.sol | 96.00 | 86.67 | 88.89 | 100.00
# Sepolia testnet first
npx hardhat run scripts/deploy.ts --network baseSepolia
# Then point the API at it
echo "CHAIN=baseSepolia\nVAULT_ADDRESS=0xYourDeployedAddress" >> .env
npx tsx api/index.tsThe deploy script auto-allowlists the canonical USDC/USDT for the target chain when known. Add more tokens via setTokensAllowed.
| Threat | Mitigation |
|---|---|
Malicious token contract reenters during transferFrom |
nonReentrant guard on pay(). |
| Payer pays the wrong amount | pay() pulls exactly expectedAmount; nothing less, nothing more. |
| Replay (paying the same invoice twice) | inv.paid flipped to true before transfer; second call reverts with InvoiceAlreadyPaid. |
Frontrunning the merchant's createInvoice |
id is caller-controlled and unique; collisions revert. Even if an attacker pre-mines an id, they'd be locking funds to their invoice, not the merchant's. |
| Webhook spoofing (attacker posts fake "paid" to merchant) | Merchant verifies HMAC against shared WEBHOOK_SECRET. |
| Gateway DB compromise | On-chain Paid events remain the source of truth; the DB is replayable from chain. |
| Token blacklists payer or payee mid-settlement | safeTransferFrom reverts atomically; invoice stays created. |
| Owner key compromised | Owner can only flip the token allowlist and pause/unpause. Cannot move user funds — vault never holds them. |
Suitable for testnet right now. For mainnet: run Slither, change the owner to a multisig, and have an auditor look at the changes you make on top.
- Solidity 0.8.23, optimizer 1M runs
- Hardhat 2.22 with
@nomicfoundation/hardhat-toolbox - OpenZeppelin Contracts v5 (
Ownable,Pausable,ReentrancyGuard,SafeERC20) - Node 20 + Express +
better-sqlite3 - Viem 2 (
watchContractEvent,parseAbi) - Zod for request validation
MIT — see LICENSE.
Author: 0xPenwright · @0xPenwright · solo Web3 engineer, available for payment-infra, vault, and integration work.
Payable in USDC / USDT on Ethereum, Polygon, Base, Arbitrum, Optimism → app.request.finance/create/633560518783137d