Damla

Build on Damla

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

1. Overview

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:

DamlaLinkDropOne-to-one. One deposit, one claim. 0x367F9BFc8E0A7270025914Eb5EF457A718bC5aE1
DamlaDropOne-to-many. One deposit splits into N equal shares; the first N distinct claimers each take one. 0xd9A80881Ac5D810043bEbF1754a7B0Ef61D7c394

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.

2. Architecture

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 gas

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

3. The signature scheme

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.

4. Contract reference

DamlaLinkDrop (one-to-one). 0x367F9BFc8E0A7270025914Eb5EF457A718bC5aE1

FunctionParamsWhat it does
deposit
payable
address linkAddr, uint64 expiryEscrows msg.value against linkAddr until expiry. One deposit per link address.
claimaddress linkAddr, address payout, bytes sigVerifies sig recovers to linkAddr, then sends the full amount to payout. Anyone can submit; only the signed payout is paid.
reclaimaddress linkAddrAfter expiry, returns the escrowed amount to the original sender. Reverts if not expired or not the sender.
getDrop
view
address linkAddrReturns (address sender, uint256 amount, uint64 expiry, bool claimed).

DamlaDrop (one-to-many). 0xd9A80881Ac5D810043bEbF1754a7B0Ef61D7c394

FunctionParamsWhat it does
createDrop
payable
address linkAddr, uint32 slots, uint64 expirySplits msg.value into slots equal shares. The first slots distinct payouts each take one share.
claimaddress linkAddr, address payout, bytes sigVerifies the signature, then sends one share to payout. Each payout address can claim at most once per pool.
reclaimaddress linkAddrAfter expiry, returns any unclaimed remainder to the original sender.
getPool
view
address linkAddrReturns (address sender, uint256 amountPerClaim, uint256 remaining, uint32 slots, uint32 claimed, uint64 expiry).

5. Relayer API reference

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.

6. Run it locally

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
Use a dedicated, low-balance mainnet key for the relayer. It only needs enough MON to cover gas for claims and the small demo sponsor. Never reuse a key that holds real value.
How it worksCommon questionsContract ↗