Damla sends money by a link on Monad. The person receiving needs no wallet and pays no gas. This page covers the two on-chain contracts, the claim signature that keeps custody trustless, the relayer API, and how to run the app locally. Everything here targets Monad (chainId 143).
On this page
Damla is a link that carries money. A sender escrows MON against a fresh, single-use key; whoever opens the link claims the funds into any address they control. There are two contracts, both live on Monad:
Walletless means the recipient never installs or connects a wallet: the page mints a throwaway key in the browser and claims to it. Gasless means the recipient holds zero balance, because a relayer submits the claim transaction and pays the gas. The relayer can only decide when a claim lands, never where the funds go, since the payout address is fixed inside a signature made by the link key.
The flow is: sender escrows into the contract, the recipient signs a payout authorization with the link key, and the relayer relays that signature on-chain while paying gas. The contract releases funds only to the signed payout.
sender wallet
|
| deposit(linkAddr, expiry) / createDrop(linkAddr, slots, expiry)
v
+-------------------+ the money is now escrowed against linkAddr,
| Damla contract | <--- released only to an address the link key signs
| (escrow on Monad)|
+-------------------+
^
| claim(linkAddr, payout, sig) <- tx submitted and paid by relayer
|
+-------------------+ recipient signs "pay this drop to <payout>"
| relayer (server) | <--- with the ephemeral link key from the URL fragment
+-------------------+
|
| contract recovers signer, checks it equals linkAddr, then pays out
v
payout address (recipient's throwaway wallet) -- receives the funds, pays no gasThe key never touches a server. It lives in the URL fragment (after the #), which browsers do not send in requests. The relayer only ever sees a finished signature plus the payout it authorizes.
A claim is authorized by an EIP-191 personal_sign over a keccak256 digest. The inner digest is:
inner = keccak256(abi.encodePacked(contractAddress, chainId, linkAddr, payout)) sig = personal_sign(inner) // signed by the ephemeral link private key
The payout address is part of the signed bytes. The contract recovers the signer from sig and requires it to equal linkAddr (the address derived from the link key). Because the relayer never holds the link key, it cannot produce a signature for a different payout. Change the payout by one character and the recovered signer no longer matches linkAddr, so the contract reverts with BadSignature. A fully compromised relayer can waste its own gas and nothing else. Here is the exact signing code with viem:
import { keccak256, encodePacked, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
// The exact digest both contracts hash over in _digest().
// keccak256(abi.encodePacked(contractAddress, chainId, linkAddr, payout))
function claimInnerDigest(
contract: `0x${string}`,
chainId: number,
linkAddr: `0x${string}`,
payout: `0x${string}`
): Hex {
return keccak256(
encodePacked(
["address", "uint256", "address", "address"],
[contract, BigInt(chainId), linkAddr, payout]
)
);
}
// The ephemeral link private key (carried in the URL fragment) signs the digest.
// signMessage with { raw } produces an EIP-191 personal_sign:
// keccak256("\x19Ethereum Signed Message:\n32" || inner)
async function signClaim(
secret: Hex, // the link private key from the fragment
contract: `0x${string}`,
chainId: number,
linkAddr: `0x${string}`, // = address derived from the secret
payout: `0x${string}` // where the funds must go
): Promise<Hex> {
const link = privateKeyToAccount(secret);
const inner = claimInnerDigest(contract, chainId, linkAddr, payout);
return link.signMessage({ message: { raw: inner } });
}Both contracts hash the same four fields in the same order, so the only difference between a link claim and a drop claim is which contract address you pass in.
DamlaLinkDrop (one-to-one). 0x367F9BFc8E0A7270025914Eb5EF457A718bC5aE1 ↗
| Function | Params | What it does |
|---|---|---|
| deposit payable | address linkAddr, uint64 expiry | Escrows msg.value against linkAddr until expiry. One deposit per link address. |
| claim | address linkAddr, address payout, bytes sig | Verifies sig recovers to linkAddr, then sends the full amount to payout. Anyone can submit; only the signed payout is paid. |
| reclaim | address linkAddr | After expiry, returns the escrowed amount to the original sender. Reverts if not expired or not the sender. |
| getDrop view | address linkAddr | Returns (address sender, uint256 amount, uint64 expiry, bool claimed). |
DamlaDrop (one-to-many). 0xd9A80881Ac5D810043bEbF1754a7B0Ef61D7c394 ↗
| Function | Params | What it does |
|---|---|---|
| createDrop payable | address linkAddr, uint32 slots, uint64 expiry | Splits msg.value into slots equal shares. The first slots distinct payouts each take one share. |
| claim | address linkAddr, address payout, bytes sig | Verifies the signature, then sends one share to payout. Each payout address can claim at most once per pool. |
| reclaim | address linkAddr | After expiry, returns any unclaimed remainder to the original sender. |
| getPool view | address linkAddr | Returns (address sender, uint256 amountPerClaim, uint256 remaining, uint32 slots, uint32 claimed, uint64 expiry). |
One endpoint, POST /api/relay, takes a JSON body with an action field. It returns { hash } on success or { error } with a 4xx status. Requests are rate limited per IP. The relayer simulates and gas-caps every transaction before submitting.
claim relays a single-link claim on DamlaLinkDrop.
POST /api/relay
Content-Type: application/json
{
"action": "claim",
"linkAddr": "0xLinkAddressDerivedFromTheSecret",
"payout": "0xRecipientPayoutAddress",
"sig": "0x<130 hex chars: r || s || v>"
}{ "hash": "0xTransactionHash" }On a failed signature or an already-claimed link, the response is a 4xx with an error string, for example:
{ "error": "Signature check failed for this payout." }dropclaim relays a share claim on DamlaDrop. Same shape as claim, but routed to the multi-claim contract.
POST /api/relay
Content-Type: application/json
{
"action": "dropclaim",
"linkAddr": "0xPoolLinkAddress",
"payout": "0xRecipientPayoutAddress",
"sig": "0x<130 hex chars>"
}{ "hash": "0xTransactionHash" }reclaimrelays a reclaim. It only succeeds if the relayer key is the original sender for that link, so in practice reclaim runs from the sender's own wallet in the app; this action exists for relayer-owned deposits.
POST /api/relay
Content-Type: application/json
{
"action": "reclaim",
"linkAddr": "0xLinkAddress"
}{ "hash": "0xTransactionHash" }sponsor funds a throwaway wallet with a small fixed amount so a walletless user can try the send side. It refuses if the target already holds a balance above the demo threshold.
POST /api/relay
Content-Type: application/json
{
"action": "sponsor",
"to": "0xThrowawayWalletAddress"
}{ "hash": "0xTransactionHash", "amount": "60000000000000000" }amount is returned in wei. The value above is 0.06 MON.
The app is a Next.js project in the web directory. Clone it, install, and start the dev server:
git clone <your-fork-url> damla cd damla/web cp .env.example .env.local # then fill in the values below npm install npm run dev # http://localhost:3000
Fill .env.local with the values below. The NEXT_PUBLIC_ vars are read by the browser and safe to expose. RELAYER_PRIVATE_KEY is server-only: it signs relayed transactions and pays gas, so keep it out of the client and out of version control.
# Client-readable (safe to expose) NEXT_PUBLIC_CONTRACT=0x367F9BFc8E0A7270025914Eb5EF457A718bC5aE1 NEXT_PUBLIC_DROP_CONTRACT=0x7d105954B5A597375CFA4b6a5e08fB8e4bfb953d NEXT_PUBLIC_CHAIN_ID=143 NEXT_PUBLIC_RPC_URL=https://rpc.monad.xyz NEXT_PUBLIC_EXPLORER=https://monadscan.com # Server-only. A funded mainnet key used ONLY to pay gas for # claim / dropclaim / reclaim and the demo sponsor. Never expose it. RELAYER_PRIVATE_KEY=0xYourFundedRelayerKey