Sign → relay → settled
The customer's signature is the payment. It binds the amount, the recipient, an expiry, and a nonce, so the merchant can submit it or not, but cannot change it.
How we know
USDG is a facet/diamond contract, so the honest way to ask whether it implements a
function is getFacet(bytes4). Reproduce all of this with
npm run verify:usdg.
robinhood (chain 4663) USDG 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168
getFacet(0xe3ee160e) transferWithAuthorization 0x780d30b6...b01309 [OK]
getFacet(0xef55bec6) receiveWithAuthorization 0x780d30b6...b01309 [OK]
getFacet(0xe94a0102) authorizationState 0x780d30b6...b01309 [OK]
getFacet(0xdeadbeef) CONTROL: not a real function not registered [OK: control is unregistered]
DOMAIN_SEPARATOR() live 0x7a3d7400b27830f4f91c2c16a082486d67c1befecaec2f53b33f1f35d5b62036
reconstructed offline 0x7a3d7400b27830f4f91c2c16a082486d67c1befecaec2f53b33f1f35d5b62036
name="Global Dollar" version="1" [OK: match]
decimals() 6 [OK]
PASS: USDG exposes EIP-3009 and the EIP-712 domain reconstructs on every network.
0xdeadbeef line is a negative control. Without it, a
getFacet that returned a facet for everything would look like proof of
support. With it, a broken probe is visibly broken.
The EIP-712 domain reconstructs offline to exactly the contract's own
DOMAIN_SEPARATOR() on both networks, so the message hood-pay asks a
customer to sign is the message the token will verify. The mainnet value is asserted
as a fixed vector in the test suite.
Why EIP-3009 and not permit
EIP-2612 permit is also registered on USDG. hood-pay does not use it,
because an allowance is the wrong shape for a checkout.
| EIP-2612 permit | EIP-3009 transferWithAuthorization | |
|---|---|---|
| What the signature authorizes | an allowance to a spender | one exact transfer |
| Steps to get paid | 2 (permit, then transferFrom) | 1 |
| Residual risk after payment | a live allowance remains | none, the nonce is spent |
| Recipient bound in signature | no | yes |
| Amount bound in signature | ceiling only | exactly |
| Replay protection | sequential nonce | arbitrary nonce, consumed on use |
(intent id, payer), so
authorizationState on the token contract becomes the double-charge guard.
Even a merchant database that got its locking wrong cannot charge the same intent
twice.
Quickstart
Node ≥ 20. viem is a peer dependency on the server; the browser bundle has none.
import express from 'express'
import { createPublicClient, createWalletClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { robinhood } from 'viem/chains'
import { HoodPayMerchant, SqliteStore, expressHoodPay } from '@three-ws/hood-pay/server'
const account = privateKeyToAccount(process.env.HOOD_PAY_RELAYER_KEY)
const transport = http('https://rpc.mainnet.chain.robinhood.com')
const merchant = new HoodPayMerchant({
payTo: '0xYourReceivingAddress',
network: 'robinhood',
baseUrl: 'https://shop.example.com',
store: new SqliteStore('./data/hood-pay.sqlite'),
relayer: {
reader: createPublicClient({ chain: robinhood, transport }),
writer: createWalletClient({ account, chain: robinhood, transport }),
address: account.address,
chain: robinhood,
maxAmountPerPayment: 1_000_000_000n,
},
})
const app = express()
app.use(express.json())
app.use(expressHoodPay({ merchant, apiKey: process.env.HOOD_PAY_API_KEY }))
app.listen(3000)
<script src="https://unpkg.com/@three-ws/hood-pay/dist/browser/hood-pay.iife.js"></script>
<hood-pay-button
intent-id="pi_2f1c..."
api-base="https://shop.example.com"
merchant-name="Example Shop"
label="Pay 12.50 USDG"
theme="auto"></hood-pay-button>
<script>
document.querySelector('hood-pay-button')
.addEventListener('hood-pay:settled', (event) => {
console.log(event.detail.receipt.digest)
})
</script>
27 kB minified, zero dependencies, everything inside a shadow root. React wrapper:
import { HoodPayButton } from '@three-ws/hood-pay/widget/react'.
const { intent, url } = await merchant.createPaymentLink({
amount: '12.50', // USDG, exact
description: 'One large widget',
metadata: { orderId: 'A-1043' },
idempotencyKey: 'order-A-1043', // repeat-safe
expiresInSeconds: 900,
})
console.log(url) // https://shop.example.com/pay/pi_2f1c...
The hosted page is one self-contained document: inline styles, inline script, no CDN, no fonts, no external requests. The amount and terms render on the server, so the customer sees them before any JavaScript runs.
import { constructWebhookEvent } from '@three-ws/hood-pay/server'
// Mount BEFORE express.json(): the signature covers the exact bytes received.
app.post('/hooks/hood-pay', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const event = await constructWebhookEvent({
secret: process.env.HOOD_PAY_WEBHOOK_SECRET,
rawBody: req.body.toString('utf8'),
signature: req.header('Hood-Pay-Signature') ?? '',
toleranceSeconds: 300,
})
if (event.type === 'payment_intent.succeeded') {
await fulfil(event.data.intent.metadata.orderId, event.data.receipt)
}
res.sendStatus(200)
} catch {
res.sendStatus(400)
}
})
Header format: Hood-Pay-Signature: t=1763558400,v1=0x9f2c..., an
HMAC-SHA-256 over ${t}.${rawBody}. Deduplicate on
Hood-Pay-Event-Id; retries are expected.
Every state is designed
The widget renders exactly one design per state, and the type system refuses a state that has no design. No fake progress bars: each state reflects a real await.
idle: priced from the intent
wallet-missing: EIP-6963 found nothing
wrong-chain: offers to switch or add
insufficient-usdg: caught before broadcast
awaiting-signature: the only prompt
relaying: merchant pays the gas
settled: receipt + explorer link
failed: retryable, nonce unspent
expired: ask for a new one
canceled: merchant withdrew it
Receipts anyone can check
A hood-pay receipt is not a claim the merchant makes. It points at on-chain facts, and anyone with an RPC URL can confirm them without asking the merchant, hood-pay, or an indexer.
import { verifyReceipt, formatVerification } from '@three-ws/hood-pay/verify'
const result = await verifyReceipt({ receipt })
console.log(formatVerification(result))
1. Transaction succeeded
It exists on chain and did not revert.
2. Target is USDG
Sent to the USDG contract for the stated network.
3. Transfer matches exactly
A Transfer log moves precisely the stated amount, payer to merchant.
4. AuthorizationUsed
The event names the same payer and the same nonce.
5. Authorization consumed
authorizationState is now true: it can never be replayed.
6. Digest recomputes
Catches tampering with fields that are not observable on chain.
verifyPaymentByHash({ txHash, network, payTo, amount })
confirms a payment from a transaction hash and what you were owed.
hood-pay or hood402
Same chain, same token, same settlement mechanism. Completely different product.
| hood-pay | hood402 | |
|---|---|---|
| Who pays | a person with a wallet | an agent or a script |
| Trigger | a payment link or a cart | an HTTP 402 response |
| Surface | hosted page, widget, webhooks | middleware, fetch wrapper, facilitator |
| Amount | exact, fixed by an intent | at least the resource price |
| Nonce | derived from the intent and payer | random per request |
| Settlement | EIP-3009 transferWithAuthorization on USDG (identical) | |
hood-pay depends on hood402 for the protocol-level pieces: the EIP-3009
ABI fragment and EIP-712 signature recovery. A parity test asserts the two packages'
chain constants match field by field, so they cannot drift.
Install
npm install @three-ws/hood-pay viem
hood-pay
Intents, amounts, EIP-712, receipts. No server or browser dependencies.
hood-pay/server
Merchant, relay, webhooks, SQLite and in-memory stores, Express and Hono adapters.
hood-pay/client
Browser SDK: wallet discovery, signing, one snapshot per state.
hood-pay/widget
The <hood-pay-button> element, plus a React wrapper.
hood-pay/verify
Standalone on-chain verification. Needs only an RPC URL.