Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

54 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DMpay

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.

Mainnet Etherscan App XMTP


What this is

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.


Live

  • Contract (mainnet): DMPayDirectV2 at 0xAB2ef1b1A39D2DA7DAC2bCD16238cC1cE5530c52, deployed at block 25169356 → Etherscan
  • App: app.dmpay.me (Cloudflare Pages) · dmpay.eth over IPFS
  • Social: @dmpayeth on X

How it works

┌──────────┐   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             │
                   └──────────────────────────────────────┘
  1. Set your price. USDC and/or ETH. Optionally enable a lifetime pass so frequent senders pay once for unlimited DMs. Any tier set to 0 is disabled.
  2. 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.
  3. Sender pays once. The contract splits payment atomically — 97.5% to you, 2.5% to the protocol. No custody, no claim flow, no escrow.
  4. 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.


Features

Paid DMs

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.

Paid groups

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.

Tips

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.

NFT sends

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.

Verified receipts

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, and ENS onboarding

/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.


Who it's for

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.

The contract

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 V1

The app

A 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.


Two deployments

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.


Repositories

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.


Architecture decisions

  • No subdomain registry. The original design authenticated users via X OAuth, minted them a handle.dmpay.eth subdomain, 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.nameExpires tells the truth.

Security notes

  • 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 write openedAt after the ETH call, 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 constant and 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 keep javascript: and data: URLs out of an <img src>.

License

MIT.


Built by Hector Morel (@ensgianteth) at RWA-ID.

About

Pay To DM On Ethereum

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages