Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ Deposits buy ad budget at face value ($1 = $1 of clicks) through crawlproof's
partner deposit API. A site is yours once we see your tag on it: every tag carries a
claim code for one person, and withdrawals wait for it.

## Credit codes and invites

- **Credit codes** (`@profullstack/redeem-credits`, unit `usd_cents`) are ad credit. Mint on dev2:
`cd /opt/ad.network && bun node_modules/@profullstack/redeem-credits/dist/cli.js mint --db /root/.local/share/ad-network/ad-network.db --unit usd_cents --amount 1000 --prefix AD --label "for X" [--max N] [--expires 90d]`
(also `list`, `revoke <id>`). Redeem at `/me` ("Have a code?"), `POST /api/v1/credits/redeem`, `adnetwork redeem`, MCP `redeem_credit_code`.
- **Invites** (`@profullstack/affiliates`): 3 per account, `/invites`, `/i/<code>` (cookie `an_invite`, accepted at the CoinPay sign-in, or paste the code on `/invites`). One accepted invite per account, accounts under 7 days old; senders must be a day old. Both sides get `ADNETWORK_INVITE_BONUS_CENTS` (default 500) of ad credit, except throwaway addresses, the inviter's own mailbox (+tag / Gmail dots), house accounts; the inviter's half stops at `ADNETWORK_INVITE_MONTHLY_CAP` (5) per 30 days.
- **Where ad credit lives.** Every grant goes to crawlproof first (`POST /api/ads/v1/grants`): it lands on the shared advertiser account as `ad_bonus_credits`, which runs ads but pays no publisher, so credit nobody paid for can never be withdrawn as crypto. Then a `deposits` row (coin `credit`, id `credit:<source>`) adds it to the person's balance here. Both are idempotent on the source.

## Run it

```sh
Expand Down
11 changes: 11 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/adnetwork",
"version": "0.1.8",
"version": "0.2.0",
"description": "ad.network: run ads, or earn from your site, in crypto. Paste a link and a budget, or paste one tag and add a wallet. Web, API, CLI, TUI and MCP; ads by crawlproof, payments by CoinPay.",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -42,10 +42,13 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.32.2",
"@profullstack/affiliates": "0.3.0",
"@profullstack/coinpay-onboard": "^0.2.0",
"@profullstack/footer": "^0.4.0",
"@profullstack/hqtui": "^0.11.0",
"@profullstack/redeem-credits": "0.2.0",
"hono": "^4.13.14",
"mailchecker": "^6.0.21",
"qrcode": "^1.5.4",
"zod": "^4.6.7"
},
Expand Down
25 changes: 25 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export const HELP = `adnetwork ${VERSION}: run ads, or earn from your site, in c
adnetwork withdraw <site>
adnetwork remove <site>

adnetwork redeem <code> redeem a credit code for ad credit
adnetwork invites your invites (scarce: 3 each)
adnetwork invite [note] make an invite link
adnetwork accept <invite-code> accept an invite on your new account

adnetwork mcp MCP server: claude mcp add adnetwork -- bunx @profullstack/adnetwork mcp
adnetwork serve [--port N] run your own instance

Expand Down Expand Up @@ -157,6 +162,26 @@ export async function main(argv = process.argv.slice(2), out: Out = console, cli
show(r, `${money(r.amount_usd)} ${r.status === 'requested' ? 'queued' : 'sent'}.`)
return 0
}
case 'redeem': {
const r = await client.redeem(need(rest[0], 'a credit code'))
show(r, `${money(r.credit_usd)} of ad credit added. Balance ${money(r.balance_usd)}.`)
return 0
}
case 'invites': {
const r = await client.invites()
show(r, `${r.quota.left} of ${r.quota.total} invites left${r.can_invite ? '' : ` (${r.why_not})`}. Each accepted invite: ${money(r.bonus_usd)} of ad credit for both of you.\n${r.invites.map((i) => ` ${i.used ? 'used' : 'open'} ${i.created_at.slice(0, 10)} ${i.note || 'invite'}`).join('\n') || ' none yet: adnetwork invite'}`)
return 0
}
case 'invite': {
const r = await client.createInvite(rest.join(' ') || undefined)
show(r, `${r.link}\nShown once. ${r.left} left.`)
return 0
}
case 'accept': {
const r = await client.acceptInvite(need(rest[0], 'an invite code'))
show(r, r.bonus_usd ? `Accepted: ${money(r.bonus_usd)} of ad credit added.` : `Accepted${r.no_bonus ? ` (no bonus: ${r.no_bonus})` : ''}.`)
return 0
}
case 'remove': {
show(await client.removeSite(need(rest[0], 'a site')), 'Ads stopped.')
return 0
Expand Down
13 changes: 13 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,19 @@ export class AdNetworkClient {
withdraw(site: string): Promise<{ amount_usd: number; status: string }> {
return this.call('POST', `/api/v1/sites/${encodeURIComponent(site)}/withdraw`)
}
/** Redeem a credit code: ad credit on your balance (spend-only, not withdrawable). */
redeem(code: string): Promise<{ ok: boolean; credit_usd: number; balance_usd: number }> {
return this.call('POST', '/api/v1/credits/redeem', { code })
}
invites(): Promise<{ quota: { total: number; used: number; left: number }; bonus_usd: number; can_invite: boolean; why_not?: string; invites: { id: string; note: string; created_at: string; expires_at: string | null; used: boolean }[] }> {
return this.call('GET', '/api/v1/invites')
}
createInvite(note?: string): Promise<{ code: string; link: string; expires_at: string | null; left: number }> {
return this.call('POST', '/api/v1/invites', note ? { note } : {})
}
acceptInvite(code: string): Promise<{ ok: boolean; bonus_usd: number; no_bonus?: string }> {
return this.call('POST', '/api/v1/invites/accept', { code })
}
removeSite(site: string): Promise<{ ok: boolean }> {
return this.call('DELETE', `/api/v1/sites/${encodeURIComponent(site)}`)
}
Expand Down
12 changes: 12 additions & 0 deletions src/crawlproof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export type SlotMoney = {
min_payout_cents: number
}

/** A spend-only ad credit grant (POST /api/ads/v1/grants). */
export type Grant = { reference: string; applied: boolean; credits: number; amount_usd: number; ad_bonus_credits: number; spend_only: boolean }

export class CrawlproofError extends Error {
constructor(message: string, readonly status: number) {
super(message)
Expand Down Expand Up @@ -122,6 +125,15 @@ export class Crawlproof {
return this.call('POST', '/api/ads/v1/deposits', input)
}

/**
* Spend-only ad credit on our advertiser account (crawlproof ad_bonus_credits):
* runs ads like a deposit, never pays a publisher, never withdrawable.
* Idempotent on `reference`: a repeat answers applied:false and grants nothing.
*/
grant(input: { amount_usd: number; reference: string }): Promise<Grant> {
return this.call('POST', '/api/ads/v1/grants', input)
}

depositStatus(paymentId: string): Promise<Deposit> {
return this.call('GET', `/api/ads/v1/deposits/${encodeURIComponent(paymentId)}`)
}
Expand Down
61 changes: 59 additions & 2 deletions src/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export function helpPage(ctx: Ctx): string {
<li>Add a wallet for USDC (Polygon, Solana, Base or Ethereum).</li>
<li>You earn about 5¢ of every ${usd(CLICK_CENTS)} paid click on your site. Withdraw any time you have ${usd(MIN_PAYOUT_CENTS)} or more; it goes straight from crawlproof to your wallet.</li>
</ol><p class="muted small">Someone else claimed your site? If they never placed their tag, add <code>&lt;meta name="ad.network" content="your code"&gt;</code> (the code is shown when you try) and claim it again.</p></div>
<div class="card" id="credit"><h2>Credit codes and invites</h2><p>A credit code adds ad credit to your balance (<a href="/me#redeem">redeem it</a>). Each account has three invites; when someone new joins with yours, you both get ad credit. Ad credit runs your ads like budget you paid for, but it is not money: it cannot be withdrawn or refunded, and clicks bought with it pay publishers nothing.</p></div>
<div class="card"><h2>Fees</h2><p>ad.network adds none. crawlproof keeps 30% of the publisher side of each click; the network fee for sending crypto is the sender's, as always.</p></div>
<div class="card" id="agents"><h2>Agents: API, CLI, MCP</h2>
<p>Make a key on <a href="/me">your page</a>, then:</p>
Expand All @@ -153,6 +154,10 @@ POST /api/v1/sites/:site/check look for the tag now
PATCH /api/v1/sites/:site {"wallet": "0x…", "coin": "usdc_pol"}
POST /api/v1/sites/:site/withdraw
DELETE /api/v1/sites/:site
POST /api/v1/credits/redeem {"code": "AD-…"} -> ad credit (spend-only)
GET /api/v1/invites your invites, 3 each
POST /api/v1/invites {"note": "for Sam"} -> an invite link
POST /api/v1/invites/accept {"code": "INV-…"} (new accounts)

-H 'authorization: Bearer an_…'</pre>
<pre>curl -fsSL ${SITE}/install.sh | sh installs the adnetwork CLI (needs bun)
Expand Down Expand Up @@ -318,7 +323,14 @@ ${s.verifiedAt ? `<p class="ok small">Seen on https://${esc(s.host)}/ ${esc(when
})
}

export function mePage(ctx: Ctx, o: { user: User; balanceCents: number; apiKey?: string; flash?: string }): string {
/** "Have a code?": redeem a credit code for ad credit. */
function redeemCard(ctx: Ctx, code = ''): string {
return `<form method="post" action="/me/redeem" class="card" id="redeem">${csrfField(ctx)}<h2>Have a code?</h2>
<p class="muted small">A credit code adds ad credit to your balance. Ad credit runs your ads like budget you paid for; it cannot be withdrawn.</p>
<div class="row"><input name="code" required placeholder="AD-XXXX-XXXX-XXXX-XXXX" value="${esc(code)}" autocomplete="off" spellcheck="false" aria-label="Credit code"><button class="btn">Redeem</button></div></form>`
}

export function mePage(ctx: Ctx, o: { user: User; balanceCents: number; apiKey?: string; flash?: string; error?: string }): string {
const u = o.user
return layout({
ctx,
Expand All @@ -328,8 +340,10 @@ export function mePage(ctx: Ctx, o: { user: User; balanceCents: number; apiKey?:
description: 'Your ad.network account.',
path: '/me',
body: `<section class="hero" style="padding-bottom:1rem"><h1>Hi${u.name ? `, ${esc(u.name.split(' ')[0])}` : ''}.</h1><p class="tag">Signed in with CoinPay as ${esc(u.email || u.name)}.</p></section>
${flashNote(o.flash)}
${flashNote(o.flash)}${flashNote(o.error, true)}
<div class="grid2"><a class="card pick" href="/advertise"><h2>Advertise</h2><p class="muted">Balance ${usd(Math.max(0, o.balanceCents))}</p></a><a class="card pick" href="/publish"><h2>Publish</h2><p class="muted">Your sites and earnings</p></a></div>
${redeemCard(ctx)}
<div class="card"><h2>Invites</h2><p class="muted small">Hand out up to three invites. When someone new joins with one, you both get ad credit. <a href="/invites">Your invites</a></p></div>
<div class="card"><h2>API key</h2>${o.apiKey ? `<p>Your key, shown once:</p><div class="address"><code>${esc(o.apiKey)}</code><button class="copy" type="button" data-copy="${esc(o.apiKey)}">Copy</button></div><p class="muted small">export ADNETWORK_KEY=${esc(o.apiKey)}</p>` : `<p class="muted small">For the API, the CLI and MCP. ${u.apiKeyHash ? 'You have one; making a new one replaces it.' : ''}</p>`}
<form method="post" action="/me/key">${csrfField(ctx)}<button class="copy">${u.apiKeyHash ? 'Replace my key' : 'Make a key'}</button></form></div>
<details class="card"><summary>Delete my account</summary><form method="post" action="/me/delete">${csrfField(ctx)}<button class="copy bad">Stop my ads and sites and delete everything ad.network keeps</button></form><p class="muted small">Withdraw your earnings first. Unspent ad budget is not refunded automatically: ask on <a href="${REPO}/issues">GitHub</a>.</p></details>`,
Expand Down Expand Up @@ -385,3 +399,46 @@ echo " export ADNETWORK_KEY=an_..."
echo " adnetwork"
`
}

export type InviteView = { id: string; note: string; createdAt: number; expiresAt: number | null; usedBy: string | null; usedAt: number | null; revokedAt?: number | null }

/** /invites: your scarce invites, an invite code to accept, and a credit code to redeem. */
export function invitesPage(
ctx: Ctx,
o: { quota: { total: number; used: number; left: number }; invites: InviteView[]; bonusCents: number; canInvite: string | null; myBonus: { paid: boolean; reason: string | null } | null; link?: string; flash?: string; error?: string; code?: string },
): string {
const now = Date.now()
const state = (i: InviteView) => (i.usedBy ? `used ${when(i.usedAt ?? now)}` : i.revokedAt ? 'withdrawn' : i.expiresAt && i.expiresAt < now ? 'expired' : `open until ${when(i.expiresAt ?? now).slice(0, 10)}`)
return layout({
ctx,
noindex: true,
script: true,
title: 'Invites · ad.network',
description: 'Your ad.network invites and credit codes.',
path: '/invites',
body: `<section class="hero" style="padding-bottom:1rem"><h1>Invites</h1><p class="tag">Invites are scarce on purpose: ${o.quota.total} each, one use, 30 days. When someone new joins with yours, you both get <b>${usd(o.bonusCents)}</b> of ad credit.</p></section>
${flashNote(o.flash)}${flashNote(o.error, true)}
<div class="card"><h2>Your invites <span class="muted small">${o.quota.left} of ${o.quota.total} left</span></h2>
${o.link ? `<p>Your new invite link, shown once:</p><div class="address"><code>${esc(o.link)}</code><button class="copy" type="button" data-copy="${esc(o.link)}">Copy</button></div>` : ''}
${o.canInvite ? `<p class="muted small">${esc(o.canInvite)}</p>` : o.quota.left > 0 ? `<form method="post" action="/invites" class="row">${csrfField(ctx)}<input name="note" maxlength="80" placeholder="Who is it for? (optional)" aria-label="Note"><button class="btn">Make an invite</button></form>` : '<p class="muted small">No invites left.</p>'}
${o.invites.length ? `<table><thead><tr><th>For</th><th>Made</th><th>Status</th></tr></thead><tbody>${o.invites.map((i) => `<tr><td>${esc(i.note || 'invite')}</td><td>${when(i.createdAt).slice(0, 10)}</td><td>${esc(state(i))}</td></tr>`).join('')}</tbody></table>` : '<p class="muted small">None yet.</p>'}</div>
<div class="card"><h2>Have an invite code?</h2>${o.myBonus ? `<p class="muted small">You joined with an invite${o.myBonus.paid ? ' and the ad credit is on your balance.' : '.'}</p>` : `<p class="muted small">New accounts (under a week old) can accept one invite.</p><form method="post" action="/invites/accept" class="row">${csrfField(ctx)}<input name="code" required placeholder="INV-XXXX-XXXX-XXXX" value="${esc(o.code ?? '')}" autocomplete="off" spellcheck="false" aria-label="Invite code"><button class="btn">Accept</button></form>`}</div>
${redeemCard(ctx)}`,
})
}

/** /i/<code>: someone sent you an invite. */
export function inviteLandingPage(ctx: Ctx, o: { code: string | null; bonusCents: number }): string {
return layout({
ctx,
noindex: true,
title: "You're invited · ad.network",
description: 'Someone invited you to ad.network: run ads, or earn from your site, in crypto.',
path: '/',
body: o.code
? `<section class="hero"><h1>You're invited</h1><p class="tag">Someone on ad.network wants you in. Sign in with CoinPay and you both get <b>${usd(o.bonusCents)}</b> of ad credit to run your first ads.</p>
<p><a class="btn big" href="${ctx.user ? `/invites?code=${encodeURIComponent(o.code)}` : '/auth/start?next=/invites'}">${ctx.user ? 'Accept on my account' : 'Sign in with CoinPay'}</a></p>
<p class="muted small">Invite <code>${esc(o.code)}</code>. For new accounts, one per account. On another device? Paste the code at <a href="/invites">/invites</a>.</p></section>`
: `<section class="hero"><h1>Not an invite</h1><p class="tag">That invite link is not right. Ask for a fresh one.</p><a class="btn" href="/">Home</a></section>`,
})
}
Loading
Loading