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 — Discover, the creator's profile and every shared link would all read Group #<id>. So a creator can 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. It's offered as an optional third transaction while the group is being created, and again at any time from group settings. Members always read the real XMTP metadata; only outsiders fall back to the published copy.
The record is written on the creator's own name, not the group's — a group has no ENS name. The creator's primary name is the precondition rather than the target: it's what tells the app which name to write to, and it's round-tripped and expiry-checked before it's trusted. Without one there is nothing to publish to, so the option is unavailable and the group stays nameless to outsiders.
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 — and it needs a primary ENS name to write to, so a creator without one can't publish at all. 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.