Pay to DM any wallet on Ethereum. A non-custodial protocol that lets any wallet owner set a price — in USDC or ETH — to receive direct messages. Senders pay once, and the conversation opens over end-to-end encrypted XMTP. No backend, no accounts, no middlemen.
DMpay is a paywall for your wallet's inbox. You publish a profile using the ENS name you already own, set a USDC and/or ETH price, and anyone can find you, pay, and start a conversation. Payment settles atomically — 97.5% lands directly in your wallet, 2.5% goes to the protocol. The chat itself is end-to-end encrypted via XMTP and portable across every XMTP client.
Once a thread is open it carries more than text: tips and NFTs move inside the conversation, and paid group chats work the same way — one payment, on-chain membership, encrypted transport.
| Path | What |
|---|---|
contracts/ |
Hardhat 3 + Solidity 0.8.28. DMPayDirectV2.sol and its tests. → protocol docs |
frontend-ipfs/ |
Vite + React 19 + Tailwind + wagmi + XMTP V3. Static build, two deploy targets. → app docs |
pitchdeck-ipfs/ |
Static pitch deck, pinned to IPFS. |
This is a backendless dApp. No servers, no databases, no off-chain trust assumptions beyond Ethereum and XMTP. The one server-side endpoint that exists — an NFT-picker proxy that keeps a marketplace API key out of the bundle — is a convenience the app degrades gracefully without.
- Contract (mainnet):
DMPayDirectV2at0xAB2ef1b1A39D2DA7DAC2bCD16238cC1cE5530c52, deployed at block 25169356 → Etherscan - App: app.dmpay.me (Cloudflare Pages) ·
dmpay.ethover IPFS - Social: @dmpayeth on X
┌──────────┐ setPrice(usdc, eth, lifetimeUsdc, lifetimeEth) ┌────────────────┐
│ Recipient│──────────────────────────────────────────────────► │ DMPayDirectV2 │
└──────────┘ │ mainnet │
│ │
┌──────────┐ openConversationUSDC / ETH(recipient) │ 97.5% → you │
│ Sender │──────────────────────────────────────────────────► │ 2.5% → fee │
└──────────┘ ┌─────────────────────── └────────────────┘
▼
recipient wallet
│
▼
┌──────────────────────────────────────┐
│ XMTP V3 (E2E encrypted) │
│ 1:1 DMs · group chats · off-chain │
│ text · tips · NFT sends │
└──────────────────────────────────────┘
- Set your price. USDC and/or ETH. Optionally enable a lifetime pass so frequent senders pay once for unlimited DMs. Any tier set to
0is disabled. - Share your profile. Your URL is
app.dmpay.me/u/<your-ens-or-address>. Anyone with the link sees your avatar, ENS records, and pricing. - Sender pays once. The contract splits payment atomically — 97.5% to you, 2.5% to the protocol. No custody, no claim flow, no escrow.
- Chat over XMTP. Messages are end-to-end encrypted with MLS, stored on the XMTP network, and readable from any XMTP client.
Identity is plain ENS. You bring the name you already own; the protocol mints nothing. The app resolves a wallet via reverse resolution, forward-resolves to confirm the round trip, and then checks BaseRegistrar.nameExpires — because an expired name still passes the round trip, so the round trip alone is not proof of ownership.
Four price tiers per recipient — per-conversation USDC, per-conversation ETH, lifetime USDC, lifetime ETH. Unlock is bidirectional: if either party has paid, both can chat freely, because an open conversation is a property of the address pair, not a directed payment.
Recipients get real control after the sale. blockSender is permanent; closeConversation puts the thread back behind the paywall so the sender must pay again to reopen it — a re-monetisation primitive rather than a ban. A lifetime pass bypasses both, deliberately: the pass is a promise, and receiver-side controls must not retroactively void something already sold.
Anyone can open a group chat with a join price and an optional member cap. On-chain membership is the source of truth for who has paid; the XMTP group is the transport, bound to the on-chain group afterwards via setGroupXmtpId. Lifetime-pass holders join that creator's groups free.
Because XMTP group metadata is encrypted to members, a non-member would otherwise see nothing. So a creator can optionally publish a public group identity as an ENS text record under me.dmpay.group.<id> on their own name — the one public keystore they already control. Members always read the real XMTP metadata; only outsiders fall back to the published copy.
Send any amount in USDC or ETH straight inside a DM or a group. Tips run through the already-deployed payMessageUSDC / payMessageETH, which take a sender-chosen amount, split 97.5 / 2.5, and emit MessagePaid. No new contract — and unlike openConversation*, tipping never touches openedAt, so tipping someone doesn't silently unlock a paid thread.
Send an NFT to someone in the conversation. This is a plain safeTransferFrom on the collection's own contract: no DMpay contract involved, no fee taken, and — worth being explicit — no marketplace API can do this for you. Only the owner's wallet can authorise a transfer. A picker grid over an indexer makes it convenient, and when that indexer fails, manual entry still sends: a contract address plus a token id is everything the transfer needs.
Tips and NFT sends ride as DMpay's own XMTP content types (dmpay.eth/tip, dmpay.eth/nft-send). Both are receipts, not instructions — the transaction is already mined before the message is sent, so a client rendering one is reporting on-chain history, not authorising anything.
A message is just bytes from a peer, and anyone can craft one claiming any amount or replaying someone else's transaction hash. So no card is trusted on its face: the app pulls the receipt, decodes the log the transaction actually emitted, and checks it against every claimed field — including that the payer matches the XMTP identity that sent the message. Amounts and artwork stay muted until that check clears, and a card that fails is labelled, not hidden, since silently dropping it would make a spoof indistinguishable from a network hiccup.
Every other XMTP client — Converse, Coinbase Wallet, xmtp.chat — lacks these codecs, so each card carries a fallback string that has to stand alone as a readable sentence. For those users, it is the message.
/discover indexes PriceSet events to list creators who are actually open for paid DMs. And for anyone who doesn't have a name yet, the app does a full in-app .eth registration through the ENS ETHRegistrarController commit/reveal flow, bundling the resolver records — address plus chosen text records — into the register call.
| Persona | Use case |
|---|---|
| Founders | Replace "DM me on Telegram" with a priced inbox. Filters tire-kickers; serious capital sources pay through. |
| Creators | Lifetime passes monetize true fans; per-DM pricing handles everyone else. Paid groups turn an audience into a room. |
| ENS domainers | Your premium ENS is already discoverable. Add a price and convert that traffic into real conversations. |
| KOLs & advisors | Charge for warm intros, deal review, DD calls. Every DM becomes a paid signal, not another archived notification. |
DMPayDirectV2.sol — one contract, no proxies, no upgradeability, no admin pause. Pricing lives in a mapping(address => Price) on the contract itself, so there is no registry lookup and no handle to mint.
| Function | Purpose |
|---|---|
setPrice(usdc, eth, lifetimeUsdc, lifetimeEth) |
Recipient sets all four tiers. 0 disables a tier. |
openConversationUSDC / ETH(recipient) |
Pay the list price to open a conversation. |
payMessageUSDC(recipient, amount) / payMessageETH(recipient) |
Sender-chosen amount. Powers tips. |
buyLifetimePassUSDC / ETH(recipient) |
One-time payment, permanent access. |
blockSender / unblockSender / closeConversation(sender) |
Receiver-side controls. |
isUnlocked(recipient, sender) |
Access check — true while openedAt > closedAt, or on a lifetime pass. |
createGroup(priceUsdc, priceEth, capacity) |
Open a paid group. capacity = 0 is unlimited. |
joinGroupUSDC / ETH(id) |
Pay once to join. |
setGroupXmtpId / removeGroupMember / closeGroup |
Creator-only group management. |
Fees. 250 basis points (2.5%), a constant — not owner-settable. USDC fees transfer straight to the treasury during payment. ETH fees can't: pushing to the treasury on every call would let a reverting treasury brick payments, so they accrue in accumulatedEthFees and the owner sweeps them with withdrawEthFees(). The owner can change the treasury address and sweep ETH fees — nothing else. There is no admin path to message someone for free or to revoke a sold lifetime pass.
Full protocol reference, event list, legacy-contract status, and the V1 → V2 migration note: contracts/README.md.
cd contracts
npx hardhat test # 12 tests for V2, 15 for V1A fully static React app — no backend, no SSR. Everything talks directly to Ethereum and XMTP.
| Layer | Choice |
|---|---|
| Build | Vite + TypeScript |
| UI | React 19 + Tailwind 3 + lucide icons |
| Wallet | wagmi v3 + viem v2 + RainbowKit (dark theme) |
| Routing | react-router-dom v7 — BrowserRouter on Pages, HashRouter on IPFS |
| Identity | ENS forward/reverse resolution + BaseRegistrar expiry check |
| Messaging | @xmtp/browser-sdk v7 (XMTP V3, MLS) + DMpay's own tip / NFT codecs |
| Route | What |
|---|---|
/ |
Landing — hero, ENS/address search, inline profile preview. |
/u/:nameOrAddress |
Public profile — avatar, ENS records, price tiers, DM CTA. |
/discover |
Creators open for paid DMs, indexed from PriceSet. |
/inbox |
Your XMTP conversations, DMs and groups. |
/settings |
Set prices, edit ENS records, register a .eth name. |
/groups/new |
Create a paid group. |
/c/:address |
DM — paywall first, then the live XMTP thread. |
/g/:id |
Group — join paywall, roster, chat. |
/privacy, /terms |
Legal. |
Setup, environment variables, and the two build modes: frontend-ipfs/README.md.
The same source ships to two hosts with different routing constraints, selected by build mode.
| Cloudflare Pages | IPFS | |
|---|---|---|
| Where | app.dmpay.me | dmpay.eth via ENS contenthash |
| Build | npm run build:pages |
npm run build:ipfs |
| Routing | Clean paths — _redirects rewrites 404s to index.html |
Hash routing — gateways hard-404 any path that isn't a real file |
| Extras | Pages Functions: per-route link previews + the NFT proxy | Static only |
Neither deploy is git-triggered. Pages goes out by direct upload (npm run deploy:pages, which passes --branch=dmpay — omit it and the build lands as a Preview instead of production), and IPFS is a manual pin followed by setting the ENS contenthash. Pushing to either remote never moves a live site.
Shared links always point at the Pages host, even from the IPFS build. A hash route is never sent to the server, so no crawler can read one and every shared profile or group would fall back to a generic card. The tradeoff is explicit: a shared link depends on that host staying up, where a dmpay.eth link would outlive it. The app itself stays reachable over IPFS either way — this only decides which URL gets pasted into a tweet.
Two remotes carry this codebase, one per deployment target:
| Remote | Repo | Deploys |
|---|---|---|
origin |
RWA-ID/DMpay-Protocol | — |
ipfs |
RWA-ID/DMpay-IPFS | — |
Development happens on the dmpay-ipfs branch, which is pushed to both.
- No subdomain registry. The original design authenticated users via X OAuth, minted them a
handle.dmpay.ethsubdomain, and pinned a generated profile page to IPFS. That flow is gone — your wallet and your own ENS name are your identity. The old subdomains still exist and still resolve; see the legacy contracts section in the protocol docs. - No X/Twitter verification. Identity is signed by your wallet, optionally enriched by your own ENS text records (
description,url,com.twitter,com.github). - No backend, no oracle, no relayer. Every read hits Ethereum directly; every write is a user-signed transaction.
- Bidirectional unlock. Payment in either direction opens the pair.
- Receipts are verified, never trusted. See Verified receipts above.
- Public group identity is opt-in. It costs a transaction, so creators choose. The public copy can drift from the encrypted one, which is why members read XMTP and only outsiders read ENS.
- Expiry is checked, not assumed. An expired ENS name passes the forward/reverse round trip. Only
BaseRegistrar.nameExpirestells the truth.
- Solidity
^0.8.20, compiled with 0.8.28. OpenZeppelin v5 (Ownable,ReentrancyGuard,SafeERC20). - Every payable and token-moving entrypoint is
nonReentrant. The conversation paths writeopenedAtafter the ETHcall, so the guard is doing real work rather than being belt-and-braces over checks-effects-interactions ordering. - No custody on the USDC path — sender→recipient and sender→treasury are two transfers inside one call. Only accrued ETH fees rest in the contract.
- The fee rate is
constantand cannot change post-deploy. - No upgradeability, no admin pause, no emergency lever. What's deployed is what runs.
- Published group metadata is attacker-controlled text from a third party's ENS name: every field is type-checked, length-capped, and images are restricted to
http(s)to keepjavascript:anddata:URLs out of an<img src>.
MIT.
Built by Hector Morel (@ensgianteth) at RWA-ID.