Skip to content

0xPenwright/crypto-payment-gateway

Repository files navigation

crypto-payment-gateway

test License: MIT Solidity

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

Why this exists

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)

Architecture

┌──────────────┐    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.

The contract

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.

The API

cp .env.example .env       # fill in PRIVATE_KEY, RPC, VAULT_ADDRESS
npx tsx api/index.ts

POST /invoices

{
  "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..."] }
}

GET /invoices/:id

Returns the current DB record, including status (created / paid / expired), paidTxHash, and webhook delivery status.

Webhook contract

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.

Run locally

git clone https://github.com/0xPenwright/crypto-payment-gateway.git
cd crypto-payment-gateway
npm install --legacy-peer-deps
npx hardhat test
npx hardhat coverage

Last coverage on this commit:

File              |  % Stmts | % Branch |  % Funcs |  % Lines
InvoiceVault.sol  |    96.00 |    86.67 |    88.89 |   100.00

Deploy

# 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.ts

The deploy script auto-allowlists the canonical USDC/USDT for the target chain when known. Add more tokens via setTokensAllowed.

Threat model

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.

Stack

  • 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

License

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

About

Non-custodial ERC-20 invoice gateway. Solidity vault + Node/Viem listener + HMAC webhooks. 100% line coverage.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors