
Creator Paywall
- 19 installs
- 21 repo stars
- Updated August 3, 2026
- starchild-ai-agent/official-skills
Helps with ai & agent building tasks during AI-assisted development.
About
creator-paywall is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- creator-paywall
- AI & Agent Building
- AI-coding skill
Creator Paywall by the numbers
- 19 all-time installs (skills.sh)
- +5 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #10,584 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/starchild-ai-agent/official-skills --skill creator-paywallAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 19 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 3, 2026 |
| Repository | starchild-ai-agent/official-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
💸 Creator Paywall
A minimal, drop-in crypto subscription paywall. Two jobs only: wallet auth + subscription verification. Built to be embedded into a creator's existing product, not to be a platform.
Connect wallet → sign SIWE message → session (wallet = identity)
│
├─ pick plan (chain + token + monthly/yearly)
├─ transfer tokens to the creator's wallet
└─ submit txHash → backend verifies on-chain → subscription activeThe core trick: the payer's address must equal the logged-in wallet. That's how a plain transfer is matched to a user with zero memos or order IDs. Match rule is from == logged-in wallet.
When to use this
- Creator built something (app, dashboard, content, API) and wants paid access.
- They want crypto payments straight to their Starchild agent wallet — no Stripe, no signup.
- Subscription model only (monthly / yearly). Not pay-per-use metering, not fiat.
If the creator needs fiat/cards or automatic recurring charges, this is the wrong tool — tell them so.
What it is
A small Node/Express + viem app. ~900 LOC, SQLite store (swappable). Chains: Ethereum, Base, BSC. Tokens: native coin or any ERC20 (including the creator's own custom token).
template/
config.js ← the ONLY file most creators edit (chains / tokens / prices / wallet)
src/auth.js ← SIWE login + requireAuth / requireSubscription middleware
src/payments.js ← on-chain verification (txHash receipt + bounded getLogs scan)
src/chains.js ← viem clients + plan/price helpers
src/db.js ← SQLite (replace with your DB when integrating)
src/server.js ← REST API wiring everything together
public/index.html ← demo frontend: 3 views (landing / paywall / member), token-based auth
README.md ← integration docs for the creator
.env.exampleQuick start (scaffold a new instance)
Step 1 — get the creator's receiving wallet. Default = their Starchild agent EVM wallet. Call the wallet_info tool and use the ethereum address.
Step 2 — scaffold. Run the init script (copies template, writes .env, npm installs):
bash skills/creator-paywall/scripts/init.sh <target_dir> <creator_wallet> [jwt_secret] [siwe_domain]
# example:
bash skills/creator-paywall/scripts/init.sh output/projects/my-paywall 0xYourAgentWalletjwt_secret auto-generates if omitted. siwe_domain defaults to localhost (set the real domain in prod).
Step 3 — set the plans. Edit config.js → PLANS: each entry is one (chain, token, monthly, yearly) the user can pick. Stablecoins recommended (stable fiat value). For custom tokens, set the correct decimals.
Step 4 — run & preview.
# foreground for a quick check, or use the preview tool:Use the preview tool: serve with dir=<target_dir>, command="node src/server.js", port=3007 (port must match PORT in .env). Then give the user /preview/<id>/.
Critical gotchas (learned building this — don't skip)
1. Preview runs in an iframe → cookies are blocked. The frontend uses token-based auth (localStorage + Authorization: Bearer), NOT cookies. If you rewrite the frontend, keep this — cookie sessions silently fail inside the preview frame (login "does nothing"). 2. Preview is served under `/preview/<id>/`. All frontend API calls MUST be relative (resolved against document.baseURI), never absolute /api/... — absolute paths hit the root domain and 404, leaving a blank page. 3. Wallets are often blocked inside iframes. UI/state works in the frame, but to actually trigger MetaMask signing/transfer the user should open the preview in a new browser tab or a wallet in-app browser, OR publish to a public URL. The frontend already warns about this. 4. Native coin (ETH/BNB) can't be log-scanned. The "Check payment" button uses getLogs which only catches ERC20 Transfer events. Native payments must be confirmed via txHash submit (the UI handles this automatically after sending; manual paste is the fallback). 5. No background polling — by design. Confirmation is on-demand only (post-payment txHash poll, "Check payment" button, manual hash). Keeps public-RPC usage tiny. Don't add a block-scanning loop. 6. Amount match uses `>=`, not `==`. Safe for fee-on-transfer / rounding. Keep it.
Integrating into the creator's own product
The whole thing reduces to two middlewares:
import { requireAuth, requireSubscription } from "./src/auth.js";
app.get("/my/premium", requireAuth, requireSubscription, (req, res) => {
// req.wallet = user address; req.subscription.expires_at = unix expiry
res.json({ ... });
});- Bring your own DB by replacing
src/db.js(everything else is storage-agnostic). - Session works via Bearer token (returned by
/api/login) — works in SPAs and iframes. - Subscriptions renew by the user paying again (
expires_atrolls forward). No on-chain auto-debit.
API surface
| Method | Route | Auth | Purpose |
|---|---|---|---|
| GET | /api/config | — | chains, plans, creator wallet |
| GET | /api/nonce?address=&chainId=&uri= | — | SIWE message to sign |
| POST | /api/login {message,signature,chainId} | — | verify → returns session token |
| POST | /api/logout | — | end session |
| GET | /api/me | token | wallet + subscription status |
| POST | /api/subscribe/intent {chainId,token,period} | token | start payment → amount + payTo |
| POST | /api/subscribe/verify {intentId,txHash} | token | confirm a tx (native + ERC20) |
| POST | /api/subscribe/check {intentId?} | token | scan for ERC20 payment |
| GET | /api/protected | token + subscription | example gated resource |
Verification rules (what counts as paid)
1. Tx confirmed (chain-specific confirmations) and succeeded 2. Recipient == creator wallet 3. Sender == logged-in wallet 4. Token matches the selected plan 5. Amount ≥ plan price (>=) 6. txHash not already used (idempotent)
Going to production
- Set a strong
JWT_SECRETand the realSIWE_DOMAINin.env. - Swap public RPCs in
config.jsfor your own (Alchemy/QuickNode) — setRPC_ETHEREUM/RPC_BASE/RPC_BSC. - Replace SQLite (
src/db.js) with your production DB. - Publish via the
community-publishskill for a public URL (also fixes the iframe-wallet limitation).
#!/usr/bin/env bash
# init.sh — scaffold a creator-paywall instance into a target directory.
#
# Usage:
# bash skills/creator-paywall/scripts/init.sh <target_dir> <creator_wallet> [jwt_secret] [siwe_domain]
#
# Example:
# bash skills/creator-paywall/scripts/init.sh output/projects/my-paywall 0xABC...123
#
# Copies the template, writes a .env, and installs dependencies.
set -euo pipefail
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$SKILL_DIR/template"
TARGET="${1:?target_dir required}"
WALLET="${2:?creator_wallet (0x...) required}"
JWT="${3:-$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')}"
DOMAIN="${4:-localhost}"
if [ -e "$TARGET" ] && [ -n "$(ls -A "$TARGET" 2>/dev/null)" ]; then
echo "⚠️ $TARGET already exists and is non-empty. Aborting to avoid overwrite." >&2
exit 1
fi
mkdir -p "$TARGET"
cp -r "$TEMPLATE/." "$TARGET/"
cat > "$TARGET/.env" <<EOF
CREATOR_WALLET=$WALLET
JWT_SECRET=$JWT
SIWE_DOMAIN=$DOMAIN
PORT=${PORT:-3007}
EOF
echo "✓ Template copied to $TARGET"
echo "✓ Wrote .env (wallet=$WALLET, domain=$DOMAIN)"
echo "→ Installing dependencies (npm install)…"
( cd "$TARGET" && npm install --no-audit --no-fund 2>&1 | tail -3 )
echo ""
echo "Done. Start it with: cd $TARGET && npm start"
echo "Then edit config.js to set your PLANS (chains / tokens / prices)."
# Copy to .env and fill in. All optional for local demo except CREATOR_WALLET + JWT_SECRET in prod.
# The wallet that receives payments (your Starchild agent EVM wallet).
CREATOR_WALLET=0xYOUR_AGENT_WALLET_ADDRESS
# Session signing secret — set a long random string in production.
JWT_SECRET=change-me-to-a-long-random-string
# Domain shown in the SIWE sign-in message (your real domain in prod).
SIWE_DOMAIN=localhost
# Optional: override public RPCs with your own (Alchemy/QuickNode) for reliability.
# RPC_ETHEREUM=
# RPC_BASE=
# RPC_BSC=
# Optional: port (default 3000)
# PORT=3000
node_modules/
.env
*.db
*.db-*
// ============================================================================
// config.js — the ONLY file most creators need to edit.
// Define which chains/tokens you accept and your subscription prices.
// ============================================================================
// Public RPCs (no API key). Swap for your own (Alchemy/QuickNode) in production.
export const CHAINS = {
1: {
name: "Ethereum",
rpc: process.env.RPC_ETHEREUM || "https://eth.llamarpc.com",
nativeSymbol: "ETH",
explorer: "https://etherscan.io",
confirmations: 2,
},
8453: {
name: "Base",
rpc: process.env.RPC_BASE || "https://mainnet.base.org",
nativeSymbol: "ETH",
explorer: "https://basescan.org",
confirmations: 3,
},
56: {
name: "BSC",
rpc: process.env.RPC_BSC || "https://bsc-dataseed.binance.org",
nativeSymbol: "BNB",
explorer: "https://bscscan.com",
confirmations: 3,
},
};
// The wallet that RECEIVES payments = your Starchild agent wallet address.
// Get it via the wallet skill: wallet_info -> evm address. Paste it here or set CREATOR_WALLET in .env.
export const CREATOR_WALLET = (process.env.CREATOR_WALLET || "0xYOUR_AGENT_WALLET_ADDRESS").toLowerCase();
// ----------------------------------------------------------------------------
// Accepted payment options. Each entry = one (chain, token, price) the user can pick.
// - token "native" means the chain's native coin (ETH / BNB).
// - For ERC20 / custom tokens, give the contract address. `decimals` is REQUIRED
// (read it once from the token contract; e.g. USDC=6, most ERC20=18).
// - price_monthly / price_yearly are HUMAN amounts (e.g. 5 = 5 USDC). Set to null to disable a period.
// ----------------------------------------------------------------------------
export const PLANS = [
// --- Stablecoins (recommended: stable price) ---
{
chainId: 8453,
symbol: "USDC",
token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
decimals: 6,
price_monthly: 5,
price_yearly: 50,
},
{
chainId: 56,
symbol: "USDT",
token: "0x55d398326f99059fF775485246999027B3197955", // USDT on BSC
decimals: 18,
price_monthly: 5,
price_yearly: 50,
},
// --- Native coin example ---
{
chainId: 8453,
symbol: "ETH",
token: "native",
decimals: 18,
price_monthly: 0.002,
price_yearly: 0.02,
},
// --- Custom token example (a creator's own token) ---
// {
// chainId: 1,
// symbol: "MYTOKEN",
// token: "0xYourTokenContract...",
// decimals: 18,
// price_monthly: 1000,
// price_yearly: 10000,
// },
];
// SIWE domain shown to users when they sign in. Set to your real domain in prod.
export const SIWE_DOMAIN = process.env.SIWE_DOMAIN || "localhost";
export const SIWE_STATEMENT = "Sign in to access your subscription.";
// JWT secret for sessions. MUST be set in .env for production.
export const JWT_SECRET = process.env.JWT_SECRET || "dev-insecure-secret-change-me";
export const SESSION_TTL_HOURS = 24 * 7; // session validity (re-sign after this)
// How far back (in blocks) the "Check payment" button scans for a matching transfer.
// Kept small to avoid heavy public-RPC calls. We anchor the scan at the block when the
// user created the payment intent, so the range stays tiny in normal use.
export const SCAN_MAX_BLOCKS = {
1: 7200, // ~1 day on Ethereum
8453: 43200, // ~1 day on Base (2s blocks)
56: 28800, // ~1 day on BSC (3s blocks)
};
{
"name": "creator-paywall",
"version": "0.1.0",
"description": "Minimal crypto subscription paywall: SIWE wallet auth + on-chain transfer verification. Drop-in for creator products.",
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"better-sqlite3": "^11.8.1",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"viem": "^2.21.55"
}
}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Creator Paywall</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background:#0b0d12;
color:#e6e8ee; margin:0; min-height:100vh; }
.wrap { max-width:560px; margin:0 auto; padding:32px 20px 64px; }
h1 { font-size:22px; margin:0 0 4px; }
h2 { font-size:16px; margin:0 0 12px; }
.sub { color:#9aa3b2; font-size:14px; margin:0 0 24px; }
button { background:#3b82f6; color:#fff; border:0; border-radius:10px; padding:12px 18px;
font-size:15px; font-weight:600; cursor:pointer; transition:.15s; width:100%; }
button:hover:not(:disabled) { background:#2f6fe0; }
button:disabled { opacity:.45; cursor:not-allowed; }
button.ghost { background:#1a202c; color:#cbd2dd; font-weight:500; }
button.ghost:hover:not(:disabled) { background:#222a38; }
.card { background:#11151d; border:1px solid #1e2430; border-radius:16px; padding:22px; margin:14px 0; }
select, input { width:100%; background:#0b0d12; color:#e6e8ee; border:1px solid #2a3140;
border-radius:10px; padding:11px; font-size:14px; }
label { display:block; font-size:12px; color:#9aa3b2; margin:14px 0 6px; text-transform:uppercase; letter-spacing:.04em; }
.topbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
.pill { font-size:12px; padding:5px 11px; border-radius:999px; background:#1a202c; color:#9aa3b2; font-family:ui-monospace,monospace; }
.pill.live { color:#34d399; } .pill.warn { color:#fbbf24; }
.seg { display:flex; gap:8px; }
.seg button { background:#0b0d12; border:1px solid #2a3140; color:#9aa3b2; font-weight:500; }
.seg button.on { background:#1e3a8a33; border-color:#3b82f6; color:#fff; }
.price { font-size:34px; font-weight:700; margin:6px 0; }
.price small { font-size:15px; color:#9aa3b2; font-weight:400; }
.muted { color:#9aa3b2; font-size:13px; line-height:1.5; }
.ok { color:#34d399; } .bad { color:#f87171; }
.hr { height:1px; background:#1e2430; margin:18px 0; border:0; }
.hero { text-align:center; padding:30px 0 10px; }
.hero .emoji { font-size:48px; }
.feat { display:flex; gap:10px; align-items:flex-start; margin:12px 0; font-size:14px; color:#cbd2dd; }
.feat .dot { color:#3b82f6; font-weight:700; }
.toast { position:fixed; left:50%; bottom:24px; transform:translateX(-50%); background:#11151d;
border:1px solid #2a3140; border-radius:10px; padding:11px 16px; font-size:13px;
max-width:90%; opacity:0; transition:.2s; pointer-events:none; }
.toast.show { opacity:1; }
.badge { display:inline-flex; align-items:center; gap:6px; background:#064e3b55; color:#34d399;
border:1px solid #065f46; border-radius:999px; padding:6px 14px; font-size:13px; font-weight:600; }
pre { background:#0b0d12; border:1px solid #1e2430; border-radius:10px; padding:14px;
overflow:auto; font-size:12px; margin:10px 0 0; }
.row2 { display:flex; gap:8px; } .row2 > * { flex:1; }
.small { font-size:12px; }
a { color:#60a5fa; }
</style>
</head>
<body>
<div class="wrap">
<!-- ============ VIEW 1: NOT LOGGED IN ============ -->
<section id="view-landing" style="display:none">
<div class="hero">
<div class="emoji">🔐</div>
<h1>Members Only</h1>
<p class="sub">Sign in with your wallet to subscribe and unlock access.</p>
</div>
<div class="card">
<div class="feat"><span class="dot">›</span><span>Connect any EVM wallet — your address is your account.</span></div>
<div class="feat"><span class="dot">›</span><span>Pay once a month or year, directly on-chain.</span></div>
<div class="feat"><span class="dot">›</span><span>No passwords, no signup forms.</span></div>
<hr class="hr" />
<button id="btn-connect">Connect wallet & sign in</button>
<p class="muted small" id="wallet-warn" style="margin-top:12px"></p>
</div>
</section>
<!-- ============ VIEW 2: LOGGED IN, NOT SUBSCRIBED ============ -->
<section id="view-paywall" style="display:none">
<div class="topbar">
<h1 style="font-size:18px">Choose your plan</h1>
<span class="pill" id="acct-2"></span>
</div>
<p class="sub">You're signed in but don't have an active subscription yet.</p>
<div class="card">
<label>Plan</label>
<select id="plan"></select>
<label>Billing period</label>
<div class="seg" id="period-seg">
<button data-p="monthly" class="on">Monthly</button>
<button data-p="yearly">Yearly</button>
</div>
<div class="price" id="price-display">—</div>
<p class="muted" id="pay-to"></p>
<button id="btn-pay">Pay & subscribe</button>
<p class="muted small" id="pay-status" style="margin-top:12px"></p>
</div>
<div class="card">
<h2>Already paid?</h2>
<p class="muted">If you sent the payment but it's not showing, confirm it here.</p>
<button id="btn-check" class="ghost" style="margin-bottom:10px">🔄 Check payment</button>
<div class="row2">
<input id="txhash" placeholder="tx hash (needed for native ETH/BNB)" />
<button id="btn-verify" class="ghost" style="width:auto; white-space:nowrap">Verify</button>
</div>
</div>
<button id="btn-logout-2" class="ghost">Sign out</button>
</section>
<!-- ============ VIEW 3: SUBSCRIBED ============ -->
<section id="view-member" style="display:none">
<div class="topbar">
<span class="badge">● Active subscription</span>
<span class="pill" id="acct-3"></span>
</div>
<div class="card" style="text-align:center">
<div style="font-size:40px">🎉</div>
<h1>You're in.</h1>
<p class="sub" id="expiry-line" style="margin-bottom:0"></p>
</div>
<div class="card">
<h2>🔓 Premium content</h2>
<p class="muted">This is gated behind <code>requireAuth + requireSubscription</code>. Fetched live from the server.</p>
<button id="btn-protected">Load premium content</button>
<pre id="protected-out" style="display:none"></pre>
</div>
<div class="card">
<h2>Renew / extend</h2>
<p class="muted">Subscriptions extend by paying again — your expiry rolls forward.</p>
<button id="btn-renew" class="ghost">Renew subscription</button>
</div>
<button id="btn-logout-3" class="ghost">Sign out</button>
</section>
</div>
<div class="toast" id="toast"></div>
<script type="module">
const $ = (id) => document.getElementById(id);
// Resolve API calls relative to the current page so it works under /preview/<id>/ proxy paths.
const apiUrl = (p) => new URL(p.replace(/^\//,""), document.baseURI).toString();
// Token-based auth (works inside iframes where cookies are blocked). Falls back to cookie too.
let authToken = localStorage.getItem("paywall_token") || null;
const api = (p, o={}) => {
const headers = { "Content-Type":"application/json", ...(o.headers||{}) };
if (authToken) headers.Authorization = "Bearer " + authToken;
return fetch(apiUrl(p), { credentials:"include", ...o, headers }).then(r=>r.json());
};
let cfg=null, account=null, period="monthly", intent=null;
function toast(msg, cls="") { const t=$("toast"); t.innerHTML=`<span class="${cls}">${msg}</span>`; t.classList.add("show"); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove("show"),4000); }
function show(view) { for (const v of ["landing","paywall","member"]) $("view-"+v).style.display = v===view?"block":"none"; }
const short = (a) => a.slice(0,6)+"…"+a.slice(-4);
// ---------- boot: decide which view ----------
async function boot() {
show("landing"); // always show something first, even if requests fail
try {
cfg = await api("/api/config");
renderPlans();
} catch (e) {
$("wallet-warn").innerHTML = "⚠️ Could not reach the server. Try reloading.";
return;
}
if (!window.ethereum) $("wallet-warn").innerHTML = "⚠️ No EVM wallet detected. Open this page in a new browser tab with MetaMask, or in your wallet's in-app browser. (Wallets are often blocked inside embedded preview frames.)";
try {
const me = await api("/api/me"); // restore session if cookie present
if (me.wallet) { account = me.wallet; routeAfterAuth(me); }
} catch (e) { /* stay on landing */ }
}
function renderPlans() {
$("plan").innerHTML = cfg.plans.map((p,i) =>
`<option value="${i}">${p.symbol} · ${p.chainName}</option>`).join("");
updatePrice();
}
function curPlan() { return cfg.plans[$("plan").value]; }
function updatePrice() {
const p = curPlan();
const amt = period==="yearly" ? p.price_yearly : p.price_monthly;
$("price-display").innerHTML = `${amt} ${p.symbol} <small>/ ${period==="yearly"?"year":"month"}</small>`;
$("pay-to").innerHTML = `Pays to creator wallet <code>${short(cfg.creatorWallet)}</code> on ${p.chainName}.`;
}
$("plan").onchange = updatePrice;
$("period-seg").onclick = (e) => {
if (!e.target.dataset.p) return;
period = e.target.dataset.p;
[...$("period-seg").children].forEach(b => b.classList.toggle("on", b.dataset.p===period));
updatePrice();
};
// ---------- login (SIWE) ----------
$("btn-connect").onclick = async () => {
if (!window.ethereum) return toast("No EVM wallet found.", "bad");
try {
const [addr] = await window.ethereum.request({ method:"eth_requestAccounts" });
account = addr;
const chainId = parseInt(await window.ethereum.request({ method:"eth_chainId" }),16);
const { message } = await api(`/api/nonce?address=${addr}&chainId=${chainId}&uri=${location.origin}`);
toast("Sign the message in your wallet…");
const signature = await window.ethereum.request({ method:"personal_sign", params:[message, addr] });
const r = await api("/api/login", { method:"POST", body: JSON.stringify({ message, signature, chainId }) });
if (!r.ok) return toast("Login failed: "+r.error, "bad");
authToken = r.token;
localStorage.setItem("paywall_token", r.token);
toast("Signed in ✓", "ok");
routeAfterAuth(await api("/api/me"));
} catch (e) { toast("Cancelled: "+(e.message||e), "bad"); }
};
function routeAfterAuth(me) {
$("acct-2").textContent = short(account);
$("acct-3").textContent = short(account);
if (me.subscribed) {
const d = new Date(me.expiresAt*1000);
$("expiry-line").textContent = "Access until " + d.toISOString().slice(0,10) + ".";
show("member");
} else {
show("paywall");
}
}
// ---------- pay ----------
async function startPayment() {
const p = curPlan();
intent = await api("/api/subscribe/intent", { method:"POST",
body: JSON.stringify({ chainId:p.chainId, token:p.token, period }) });
if (intent.error) { toast("Error: "+intent.error, "bad"); return null; }
await switchChain(p.chainId);
const txHash = p.token==="native"
? await sendNative(intent.payTo, intent.amount)
: await sendErc20(p.token, intent.payTo, intent.amount);
return txHash;
}
$("btn-pay").onclick = async () => {
$("btn-pay").disabled = true; $("pay-status").innerHTML = "";
try {
const txHash = await startPayment();
if (!txHash) { $("btn-pay").disabled=false; return; }
$("txhash").value = txHash;
$("pay-status").innerHTML = `<span class="muted">Tx sent. Confirming on-chain…</span>`;
pollVerify(txHash);
} catch (e) { toast("Payment cancelled.", "bad"); $("pay-status").innerHTML=`<span class="bad">${e.message||e}</span>`; $("btn-pay").disabled=false; }
};
async function pollVerify(txHash, n=0) {
const r = await api("/api/subscribe/verify", { method:"POST", body: JSON.stringify({ intentId:intent.intentId, txHash }) });
if (r.ok) { toast("Subscription active ✓","ok"); return routeAfterAuth(await api("/api/me")); }
if (n < 15) { $("pay-status").innerHTML = `<span class="muted">Waiting for confirmations… (${r.error})</span>`; setTimeout(()=>pollVerify(txHash,n+1), 5000); }
else { $("pay-status").innerHTML = `<span class="warn">Not confirmed yet. Try “Check payment” below in a moment.</span>`; $("btn-pay").disabled=false; }
}
$("btn-check").onclick = async () => {
$("btn-check").disabled=true;
const r = await api("/api/subscribe/check", { method:"POST", body: JSON.stringify({ intentId: intent?.intentId }) });
$("btn-check").disabled=false;
if (r.found) { toast("Payment found ✓","ok"); routeAfterAuth(await api("/api/me")); }
else toast("Not found yet: "+r.reason, "bad");
};
$("btn-verify").onclick = async () => {
if (!intent) return toast("Start a payment first.", "bad");
const txHash = $("txhash").value.trim();
if (!/^0x[0-9a-fA-F]{64}$/.test(txHash)) return toast("Enter a valid tx hash.", "bad");
const r = await api("/api/subscribe/verify", { method:"POST", body: JSON.stringify({ intentId:intent.intentId, txHash }) });
if (r.ok) { toast("Verified ✓","ok"); routeAfterAuth(await api("/api/me")); }
else toast("Verify: "+r.error, "bad");
};
// ---------- member view ----------
$("btn-protected").onclick = async () => {
const r = await api("/api/protected");
const out = $("protected-out"); out.style.display="block";
out.textContent = JSON.stringify(r, null, 2);
if (r.error) out.className="bad";
};
$("btn-renew").onclick = async () => {
try {
const txHash = await startPayment();
if (!txHash) return;
toast("Renewal tx sent, confirming…");
pollVerify(txHash);
} catch(e){ toast("Cancelled.", "bad"); }
};
for (const id of ["btn-logout-2","btn-logout-3"]) $(id).onclick = async () => {
await api("/api/logout", { method:"POST" });
account=null; authToken=null; intent=null; localStorage.removeItem("paywall_token");
show("landing"); toast("Signed out.");
};
// ---------- raw EIP-1193 helpers ----------
async function switchChain(chainId) {
const hex="0x"+chainId.toString(16);
try { await window.ethereum.request({ method:"wallet_switchEthereumChain", params:[{chainId:hex}] }); }
catch(e){ if (e.code===4902) throw new Error("Add this network to your wallet first."); throw e; }
}
async function sendNative(to, wei) {
return window.ethereum.request({ method:"eth_sendTransaction",
params:[{ from:account, to, value:"0x"+BigInt(wei).toString(16) }] });
}
async function sendErc20(tk, to, wei) {
const data="0xa9059cbb"+to.toLowerCase().replace("0x","").padStart(64,"0")+BigInt(wei).toString(16).padStart(64,"0");
return window.ethereum.request({ method:"eth_sendTransaction", params:[{ from:account, to:tk, data }] });
}
boot();
</script>
</body>
</html>
Creator Paywall
A minimal crypto subscription paywall. Users log in with an EVM wallet (SIWE), pay a subscription by transferring tokens to your wallet, and get access — verified on-chain. No payment processor, no third-party account, no recurring on-chain approvals.
Built to be dropped into your own product. It does two things and nothing more: wallet auth + subscription verification.
How it works
User connects wallet ──► signs SIWE message ──► session (wallet = identity)
│
├─ picks plan (chain + token + monthly/yearly)
├─ transfers tokens to YOUR wallet
└─ frontend submits txHash ──► backend verifies on-chain ──► subscription activeThe key trick: the payer's address must equal the logged-in wallet. That's how a plain transfer is matched to a user with zero memos or order IDs.
Supported chains: Ethereum, Base, BSC. Tokens: any ERC20 (incl. your own custom token) or the native coin. All configured in one file.
Quick start
npm install
cp .env.example .env # set CREATOR_WALLET (your Starchild agent wallet) + JWT_SECRET
npm start # http://localhost:3000Edit `config.js` — the only file you normally touch:
CREATOR_WALLET— where payments land (your agent's EVM address)PLANS— which (chain, token, price) combos you sell, monthly/yearlyCHAINS— swap public RPCs for your own (Alchemy/QuickNode) in production
Payment confirmation
No background polling (keeps RPC usage tiny). Confirmation happens on-demand: 1. After paying in the UI — frontend submits the txHash, backend verifies (poll a few times). 2. "Check payment" button — scans recent token transfers from the user to your wallet (single bounded getLogs call, anchored at the block the payment started). 3. "Verify hash" — user pastes a txHash manually. Required for native ETH/BNB (native transfers can't be log-scanned).
API
| Method | Route | Auth | Purpose |
|---|---|---|---|
| GET | /api/config | — | chains, plans, creator wallet |
| GET | /api/nonce?address=&chainId=&uri= | — | get SIWE message to sign |
| POST | /api/login {message,signature,chainId} | — | verify signature → session cookie + token |
| POST | /api/logout | — | clear session |
| GET | /api/me | session | wallet + subscription status |
| POST | /api/subscribe/intent {chainId,token,period} | session | start payment, returns amount + payTo |
| POST | /api/subscribe/verify {intentId,txHash} | session | confirm a specific tx (native + ERC20) |
| POST | /api/subscribe/check {intentId?} | session | scan for payment (ERC20) |
| GET | /api/protected | session + subscription | example gated resource |
Session works via httpOnly cookie or `Authorization: Bearer <token>` — use whichever fits your app.
Integrating into your product
The whole thing is two middlewares:
import { requireAuth, requireSubscription } from "./src/auth.js";
// gate any route behind a paid subscription
app.get("/my/premium/route", requireAuth, requireSubscription, (req, res) => {
// req.wallet = the user's address
// req.subscription.expires_at = unix expiry
res.json({ ... });
});Bring your own DB by replacing src/db.js (it's ~120 lines of SQLite). Everything else (chains.js, payments.js, auth.js) is storage-agnostic.
Verification rules (what counts as a valid payment)
1. Tx is confirmed (chain-specific confirmations) and succeeded 2. Recipient == CREATOR_WALLET 3. Sender == the logged-in wallet 4. Token matches the selected plan 5. Amount transferred ≥ the plan price (>=, not == — safe for fee-on-transfer tokens) 6. txHash hasn't been used before (idempotent)
Notes & limits (by design)
- Stablecoins recommended. Pricing native/volatile tokens at a fixed amount means the
fiat value drifts. USDC/USDT keep prices stable.
- No auto-renew. On-chain subscriptions renew by the user sending another payment
(which extends expires_at). No allowance/delegation — safer, simpler.
- Custom tokens: set the correct
decimals. Fee-on-transfer tokens work because matching
uses the actual received amount.
- Not a payment processor. No refunds, disputes, or fiat. This is wallet-to-wallet.
- This template stays intentionally small. Add notifications, creator dashboards, multi-creator
routing, etc. on top — the core won't fight you.
// auth.js — SIWE (Sign-In With Ethereum). Wallet address IS the identity.
import { createSiweMessage, verifySiweMessage } from "viem/siwe";
import jwt from "jsonwebtoken";
import { client } from "./chains.js";
import { SIWE_DOMAIN, SIWE_STATEMENT, JWT_SECRET, SESSION_TTL_HOURS } from "../config.js";
import { saveNonce, takeNonce, getSubscription, now } from "./db.js";
const randomNonce = () =>
[...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, "0")).join("");
// Build the exact SIWE message the client must sign. We send it ready-made so
// the signed message and the server-verified message are byte-identical.
export function buildLoginMessage({ address, chainId, uri }) {
const nonce = randomNonce();
saveNonce(address, nonce);
const message = createSiweMessage({
domain: SIWE_DOMAIN,
address,
statement: SIWE_STATEMENT,
uri: uri || `http://${SIWE_DOMAIN}`,
version: "1",
chainId: Number(chainId) || 1,
nonce,
});
return message;
}
// Verify the signed message, consume the nonce, issue a session JWT.
export async function login({ message, signature, chainId }) {
// pull address + nonce out of the message to validate the nonce we issued
const addrMatch = message.match(/\n(0x[0-9a-fA-F]{40})\n/);
const nonceMatch = message.match(/Nonce: (\w+)/);
if (!addrMatch || !nonceMatch) throw new Error("malformed SIWE message");
const address = addrMatch[1];
const expected = takeNonce(address);
if (!expected || expected !== nonceMatch[1]) throw new Error("invalid or expired nonce");
const valid = await verifySiweMessage(client(Number(chainId) || 1), { message, signature });
if (!valid) throw new Error("signature verification failed");
const token = jwt.sign({ sub: address.toLowerCase() }, JWT_SECRET, {
expiresIn: `${SESSION_TTL_HOURS}h`,
});
return { token, address: address.toLowerCase() };
}
export function verifySession(token) {
try {
return jwt.verify(token, JWT_SECRET).sub;
} catch {
return null;
}
}
// Express middleware: attaches req.wallet if logged in.
export function requireAuth(req, res, next) {
const token = req.cookies?.session || (req.headers.authorization || "").replace("Bearer ", "");
const wallet = token && verifySession(token);
if (!wallet) return res.status(401).json({ error: "not authenticated" });
req.wallet = wallet;
next();
}
// Express middleware: requires an active (non-expired) subscription.
export function requireSubscription(req, res, next) {
const sub = getSubscription(req.wallet);
if (!sub || sub.expires_at <= now())
return res.status(402).json({ error: "subscription required", subscribed: false });
req.subscription = sub;
next();
}
// chains.js — viem clients + plan helpers. One public client per chain, lazily created.
import { createPublicClient, http, parseUnits } from "viem";
import { CHAINS, PLANS } from "../config.js";
const clients = {};
export function client(chainId) {
if (!CHAINS[chainId]) throw new Error(`Unsupported chainId ${chainId}`);
if (!clients[chainId]) {
clients[chainId] = createPublicClient({ transport: http(CHAINS[chainId].rpc) });
}
return clients[chainId];
}
// Find a plan by (chainId, token). token is "native" or a contract address.
export function findPlan(chainId, token) {
const t = String(token).toLowerCase();
return (
PLANS.find(
(p) => p.chainId === Number(chainId) && p.token.toLowerCase() === t
) || null
);
}
// Price (human) for a plan + period -> base units (bigint as string).
export function expectedAmountWei(plan, period) {
const price = period === "yearly" ? plan.price_yearly : plan.price_monthly;
if (price == null) throw new Error(`Period ${period} not offered for ${plan.symbol}`);
return parseUnits(String(price), plan.decimals).toString();
}
export function periodSeconds(period) {
return period === "yearly" ? 365 * 24 * 3600 : 30 * 24 * 3600;
}
// Public, client-safe view of plans (no secrets).
export function publicPlans() {
return PLANS.map((p) => ({
chainId: p.chainId,
chainName: CHAINS[p.chainId]?.name,
symbol: p.symbol,
token: p.token,
decimals: p.decimals,
price_monthly: p.price_monthly,
price_yearly: p.price_yearly,
}));
}
// db.js — tiny SQLite store. Swap for Postgres/your ORM when integrating.
import Database from "better-sqlite3";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const db = new Database(join(__dirname, "..", "paywall.db"));
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS nonces (
address TEXT PRIMARY KEY,
nonce TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
-- A subscription = one row per (user wallet). expires_at drives access.
CREATE TABLE IF NOT EXISTS subscriptions (
wallet TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Pending payment intents. Anchors the on-chain scan to a start block.
CREATE TABLE IF NOT EXISTS intents (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
chain_id INTEGER NOT NULL,
token TEXT NOT NULL,
period TEXT NOT NULL, -- 'monthly' | 'yearly'
amount_wei TEXT NOT NULL, -- expected min amount, base units
from_block INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | paid
tx_hash TEXT,
created_at INTEGER NOT NULL
);
-- Ledger of accepted payments (idempotency + creator accounting).
CREATE TABLE IF NOT EXISTS payments (
tx_hash TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
chain_id INTEGER NOT NULL,
token TEXT NOT NULL,
amount_wei TEXT NOT NULL,
period TEXT NOT NULL,
created_at INTEGER NOT NULL
);
`);
export const now = () => Math.floor(Date.now() / 1000);
// --- nonces ---
export function saveNonce(address, nonce, ttlSec = 600) {
db.prepare(
`INSERT INTO nonces(address,nonce,expires_at) VALUES(?,?,?)
ON CONFLICT(address) DO UPDATE SET nonce=excluded.nonce, expires_at=excluded.expires_at`
).run(address.toLowerCase(), nonce, now() + ttlSec);
}
export function takeNonce(address) {
const row = db.prepare(`SELECT nonce,expires_at FROM nonces WHERE address=?`).get(address.toLowerCase());
db.prepare(`DELETE FROM nonces WHERE address=?`).run(address.toLowerCase());
if (!row || row.expires_at < now()) return null;
return row.nonce;
}
// --- subscriptions ---
export function getSubscription(wallet) {
return db.prepare(`SELECT * FROM subscriptions WHERE wallet=?`).get(wallet.toLowerCase()) || null;
}
export function extendSubscription(wallet, addSeconds) {
const w = wallet.toLowerCase();
const cur = getSubscription(w);
const base = cur && cur.expires_at > now() ? cur.expires_at : now();
const expires = base + addSeconds;
db.prepare(
`INSERT INTO subscriptions(wallet,expires_at,updated_at) VALUES(?,?,?)
ON CONFLICT(wallet) DO UPDATE SET expires_at=excluded.expires_at, updated_at=excluded.updated_at`
).run(w, expires, now());
return expires;
}
// --- intents ---
export function createIntent(i) {
db.prepare(
`INSERT INTO intents(id,wallet,chain_id,token,period,amount_wei,from_block,status,created_at)
VALUES(@id,@wallet,@chain_id,@token,@period,@amount_wei,@from_block,'pending',@created_at)`
).run(i);
}
export function getIntent(id) {
return db.prepare(`SELECT * FROM intents WHERE id=?`).get(id) || null;
}
export function getLatestPendingIntent(wallet) {
return db.prepare(
`SELECT * FROM intents WHERE wallet=? AND status='pending' ORDER BY created_at DESC LIMIT 1`
).get(wallet.toLowerCase()) || null;
}
export function markIntentPaid(id, txHash) {
db.prepare(`UPDATE intents SET status='paid', tx_hash=? WHERE id=?`).run(txHash, id);
}
// --- payments (idempotency) ---
export function paymentExists(txHash) {
return !!db.prepare(`SELECT 1 FROM payments WHERE tx_hash=?`).get(txHash.toLowerCase());
}
export function recordPayment(p) {
db.prepare(
`INSERT OR IGNORE INTO payments(tx_hash,wallet,chain_id,token,amount_wei,period,created_at)
VALUES(@tx_hash,@wallet,@chain_id,@token,@amount_wei,@period,@created_at)`
).run(p);
}
export default db;
// payments.js — the heart of the paywall: verify an on-chain transfer matches an intent.
//
// Matching rules (all must hold):
// 1. tx confirmed (>= configured confirmations) and successful
// 2. recipient == CREATOR_WALLET
// 3. sender == the logged-in user's wallet <-- this is the identity = payer link
// 4. token matches the chosen plan
// 5. transferred amount >= expected price
// 6. txHash not already consumed (idempotent)
import { getAddress, parseAbiItem } from "viem";
import { CREATOR_WALLET, CHAINS, SCAN_MAX_BLOCKS } from "../config.js";
import { client } from "./chains.js";
const TRANSFER_EVENT = parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
);
const eqAddr = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
// Verify a specific txHash against an intent. Returns { ok, reason?, amount }.
export async function verifyTxHash({ chainId, token, expectedWei, wallet, txHash }) {
const c = client(chainId);
const conf = CHAINS[chainId].confirmations;
let receipt;
try {
receipt = await c.getTransactionReceipt({ hash: txHash });
} catch {
return { ok: false, reason: "tx not found yet — wait for it to be mined" };
}
if (receipt.status !== "success") return { ok: false, reason: "tx failed on-chain" };
const latest = await c.getBlockNumber();
const confirmations = Number(latest - receipt.blockNumber) + 1;
if (confirmations < conf)
return { ok: false, reason: `waiting for confirmations (${confirmations}/${conf})` };
if (token === "native") {
const tx = await c.getTransaction({ hash: txHash });
if (!eqAddr(tx.from, wallet)) return { ok: false, reason: "sender is not your wallet" };
if (!tx.to || !eqAddr(tx.to, CREATOR_WALLET))
return { ok: false, reason: "recipient is not the creator wallet" };
if (tx.value < BigInt(expectedWei))
return { ok: false, reason: "amount below required price" };
return { ok: true, amount: tx.value.toString() };
}
// ERC20: find a matching Transfer log emitted by the token contract.
for (const log of receipt.logs) {
if (!eqAddr(log.address, token)) continue;
try {
const decoded = decodeTransfer(log);
if (!decoded) continue;
if (!eqAddr(decoded.from, wallet)) continue;
if (!eqAddr(decoded.to, CREATOR_WALLET)) continue;
if (decoded.value < BigInt(expectedWei)) continue;
return { ok: true, amount: decoded.value.toString() };
} catch {
continue;
}
}
return { ok: false, reason: "no matching token transfer found in this tx" };
}
function decodeTransfer(log) {
// Transfer(address,address,uint256): topic0 = sig, topic1 = from, topic2 = to, data = value
const sig = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
if (log.topics[0]?.toLowerCase() !== sig) return null;
const from = "0x" + log.topics[1].slice(26);
const to = "0x" + log.topics[2].slice(26);
const value = BigInt(log.data);
return { from: getAddress(from), to: getAddress(to), value };
}
// On-demand scan (the "Check payment" button) for ERC20 transfers from user -> creator.
// Anchored at intent.from_block so the range stays tiny. Returns txHash or null.
// NOTE: native-coin payments cannot be log-scanned — those must be confirmed via txHash.
export async function scanForPayment({ chainId, token, expectedWei, wallet, fromBlock }) {
if (token === "native")
return { found: false, reason: "native payments: submit the tx hash to confirm" };
const c = client(chainId);
const latest = await c.getBlockNumber();
const maxSpan = BigInt(SCAN_MAX_BLOCKS[chainId] || 20000);
let start = BigInt(fromBlock);
if (latest - start > maxSpan) start = latest - maxSpan; // clamp
const logs = await c.getLogs({
address: token,
event: TRANSFER_EVENT,
args: { from: getAddress(wallet), to: getAddress(CREATOR_WALLET) },
fromBlock: start,
toBlock: latest,
});
for (const log of logs) {
if (log.args.value >= BigInt(expectedWei)) {
return { found: true, txHash: log.transactionHash, amount: log.args.value.toString() };
}
}
return { found: false, reason: "no matching transfer found yet" };
}
// server.js — minimal REST API for the paywall. Mount these routes in your own app,
// or run standalone for the demo. All money/identity logic lives in the imported modules.
import "dotenv/config";
import express from "express";
import cookieParser from "cookie-parser";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { randomUUID } from "crypto";
import { CREATOR_WALLET, CHAINS, SESSION_TTL_HOURS } from "../config.js";
import { publicPlans, findPlan, expectedAmountWei, periodSeconds, client } from "./chains.js";
import { buildLoginMessage, login, requireAuth, requireSubscription } from "./auth.js";
import { verifyTxHash, scanForPayment } from "./payments.js";
import {
createIntent, getIntent, getLatestPendingIntent, markIntentPaid,
paymentExists, recordPayment, extendSubscription, getSubscription, now,
} from "./db.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(express.static(join(__dirname, "..", "public")));
const cookieOpts = { httpOnly: true, sameSite: "lax", maxAge: SESSION_TTL_HOURS * 3600 * 1000 };
// ---- config / plans (public) -------------------------------------------------
app.get("/api/config", (req, res) => {
res.json({ creatorWallet: CREATOR_WALLET, chains: CHAINS, plans: publicPlans() });
});
// ---- auth (SIWE) -------------------------------------------------------------
app.get("/api/nonce", (req, res) => {
const { address, chainId, uri } = req.query;
if (!address) return res.status(400).json({ error: "address required" });
res.json({ message: buildLoginMessage({ address, chainId, uri }) });
});
app.post("/api/login", async (req, res) => {
try {
const { token, address } = await login(req.body);
res.cookie("session", token, cookieOpts);
res.json({ ok: true, address, token });
} catch (e) {
res.status(401).json({ error: e.message });
}
});
app.post("/api/logout", (req, res) => {
res.clearCookie("session");
res.json({ ok: true });
});
// ---- current user + subscription status -------------------------------------
app.get("/api/me", requireAuth, (req, res) => {
const sub = getSubscription(req.wallet);
const active = sub && sub.expires_at > now();
res.json({
wallet: req.wallet,
subscribed: !!active,
expiresAt: active ? sub.expires_at : null,
});
});
// ---- subscribe step 1: create intent (anchors the scan start block) ----------
app.post("/api/subscribe/intent", requireAuth, async (req, res) => {
try {
const { chainId, token, period } = req.body;
const plan = findPlan(chainId, token);
if (!plan) return res.status(400).json({ error: "no such plan" });
if (!["monthly", "yearly"].includes(period))
return res.status(400).json({ error: "period must be monthly|yearly" });
const amount_wei = expectedAmountWei(plan, period);
const from_block = Number(await client(Number(chainId)).getBlockNumber());
const id = randomUUID();
createIntent({
id, wallet: req.wallet, chain_id: Number(chainId), token: String(token),
period, amount_wei, from_block, created_at: now(),
});
res.json({
intentId: id,
payTo: CREATOR_WALLET,
chainId: Number(chainId),
token: String(token),
symbol: plan.symbol,
decimals: plan.decimals,
amount: amount_wei, // base units to send (>=)
});
} catch (e) {
res.status(400).json({ error: e.message });
}
});
// ---- subscribe step 2a: confirm via txHash (works for native + ERC20) --------
app.post("/api/subscribe/verify", requireAuth, async (req, res) => {
try {
const { intentId, txHash } = req.body;
const intent = await loadOwnedIntent(intentId, req.wallet, res);
if (!intent) return;
if (!txHash || !/^0x[0-9a-fA-F]{64}$/.test(txHash))
return res.status(400).json({ error: "valid txHash required" });
if (paymentExists(txHash)) return res.status(409).json({ error: "tx already used" });
const r = await verifyTxHash({
chainId: intent.chain_id, token: intent.token,
expectedWei: intent.amount_wei, wallet: req.wallet, txHash,
});
if (!r.ok) return res.status(400).json({ error: r.reason });
return res.json(activate(intent, txHash, r.amount));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ---- subscribe step 2b: "Check payment" button (ERC20 log scan) --------------
app.post("/api/subscribe/check", requireAuth, async (req, res) => {
try {
const intent = req.body.intentId
? await loadOwnedIntent(req.body.intentId, req.wallet, res)
: getLatestPendingIntent(req.wallet);
if (!intent) return res.json({ found: false, reason: "no pending payment" });
const r = await scanForPayment({
chainId: intent.chain_id, token: intent.token,
expectedWei: intent.amount_wei, wallet: req.wallet, fromBlock: intent.from_block,
});
if (!r.found) return res.json({ found: false, reason: r.reason });
if (paymentExists(r.txHash)) return res.json({ found: false, reason: "tx already used" });
return res.json({ found: true, ...activate(intent, r.txHash, r.amount) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ---- example protected resource ---------------------------------------------
app.get("/api/protected", requireAuth, requireSubscription, (req, res) => {
res.json({ ok: true, secret: "🎉 premium content for " + req.wallet });
});
// ---- helpers ----------------------------------------------------------------
async function loadOwnedIntent(intentId, wallet, res) {
if (!intentId) { res.status(400).json({ error: "intentId required" }); return null; }
const intent = getIntent(intentId);
if (!intent || intent.wallet !== wallet) { res.status(404).json({ error: "intent not found" }); return null; }
return intent;
}
function activate(intent, txHash, amount) {
recordPayment({
tx_hash: txHash.toLowerCase(), wallet: intent.wallet, chain_id: intent.chain_id,
token: intent.token, amount_wei: amount, period: intent.period, created_at: now(),
});
markIntentPaid(intent.id, txHash.toLowerCase());
const expiresAt = extendSubscription(intent.wallet, periodSeconds(intent.period));
return { ok: true, subscribed: true, expiresAt, txHash };
}
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Creator paywall running on http://localhost:${PORT}`));