Checkout on Robinhood Chain,
without ever asking for gas

An embeddable widget, hosted payment links, and a merchant verification library for USDG on chain 4663. The customer signs one message. They never send a transaction, never hold a native token, and never see the word "gas".

$ npm install @three-ws/hood-pay viem Get started Why it works

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.

customer leg (a signature, not a transaction) settlement leg (EIP-3009 transferWithAuthorization)

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.
The 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 permitEIP-3009 transferWithAuthorization
What the signature authorizesan allowance to a spenderone exact transfer
Steps to get paid2 (permit, then transferFrom)1
Residual risk after paymenta live allowance remainsnone, the nonce is spent
Recipient bound in signaturenoyes
Amount bound in signatureceiling onlyexactly
Replay protectionsequential noncearbitrary nonce, consumed on use
hood-pay derives the EIP-3009 nonce from (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'.

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.

Ready to pay

idle: priced from the intent

No wallet detected

wallet-missing: EIP-6963 found nothing

Wrong network

wrong-chain: offers to switch or add

Not enough USDG

insufficient-usdg: caught before broadcast

Waiting for your wallet

awaiting-signature: the only prompt

Settling on-chain

relaying: merchant pays the gas

Payment complete

settled: receipt + explorer link

Payment failed

failed: retryable, nonce unspent

Link expired

expired: ask for a new one

Payment canceled

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.

No receipt at all? 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-payhood402
Who paysa person with a walletan agent or a script
Triggera payment link or a cartan HTTP 402 response
Surfacehosted page, widget, webhooksmiddleware, fetch wrapper, facilitator
Amountexact, fixed by an intentat least the resource price
Noncederived from the intent and payerrandom per request
SettlementEIP-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.