Skip to content

Commit c3cec13

Browse files
author
CloudLobster
committed
feat: USDC send supports Base Mainnet + Base Sepolia (user-selectable network)
- Worker: multi-network USDC verification (base-mainnet / base-sepolia) - Frontend: network toggle in Send USDC modal (mainnet default) - DB: usdc_network column + auto-migration - Dynamic explorer links, warning for real USDC sends - Backward compatible: defaults to base-sepolia if network not specified
1 parent 4c410e1 commit c3cec13

File tree

3 files changed

+124
-40
lines changed

3 files changed

+124
-40
lines changed

web/src/pages/Dashboard.tsx

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ interface EmailItem {
3737
created_at: number;
3838
usdc_amount?: string | null;
3939
usdc_tx?: string | null;
40+
usdc_network?: string | null;
4041
}
4142

4243
interface AuthState {
@@ -662,11 +663,32 @@ export default function Dashboard() {
662663
);
663664
}
664665

665-
// ─── USDC Send Modal (Base Sepolia Testnet) ────────────
666+
// ─── USDC Send Modal (Base Mainnet + Base Sepolia) ────────────
667+
type UsdcNetwork = 'base-mainnet' | 'base-sepolia';
668+
const USDC_NET_CONFIG: Record<UsdcNetwork, { chainId: number; usdc: `0x${string}`; label: string; badge: string; badgeColor: string; explorer: string }> = {
669+
'base-mainnet': {
670+
chainId: base.id,
671+
usdc: BASE_MAINNET_USDC,
672+
label: 'Base Mainnet',
673+
badge: '💰 Real USDC',
674+
badgeColor: 'text-green-400 bg-green-900/30',
675+
explorer: 'https://basescan.org',
676+
},
677+
'base-sepolia': {
678+
chainId: BASE_SEPOLIA_CHAIN_ID,
679+
usdc: BASE_SEPOLIA_USDC,
680+
label: 'Base Sepolia (Testnet)',
681+
badge: '🧪 Testnet',
682+
badgeColor: 'text-purple-400 bg-purple-900/30',
683+
explorer: 'https://sepolia.basescan.org',
684+
},
685+
};
686+
666687
function UsdcSendModal({ auth, onClose }: { auth: AuthState; onClose: () => void }) {
667688
const { switchChainAsync } = useSwitchChain();
668689
const [recipient, setRecipient] = useState('');
669690
const [amount, setAmount] = useState('');
691+
const [network, setNetwork] = useState<UsdcNetwork>('base-mainnet');
670692
const [recipientWallet, setRecipientWallet] = useState('');
671693
const [resolving, setResolving] = useState(false);
672694
const [resolveError, setResolveError] = useState('');
@@ -705,10 +727,12 @@ function UsdcSendModal({ auth, onClose }: { auth: AuthState; onClose: () => void
705727
if (!recipientWallet || !amount || parseFloat(amount) <= 0) return;
706728
setError('');
707729

730+
const net = USDC_NET_CONFIG[network];
731+
708732
try {
709-
// 1. Switch to Base Sepolia
733+
// 1. Switch to selected network
710734
setStatus('switching');
711-
await switchChainAsync({ chainId: BASE_SEPOLIA_CHAIN_ID });
735+
await switchChainAsync({ chainId: net.chainId });
712736

713737
// 2. Transfer USDC
714738
setStatus('transferring');
@@ -717,35 +741,27 @@ function UsdcSendModal({ auth, onClose }: { auth: AuthState; onClose: () => void
717741
const memo = new TextEncoder().encode(`basemail:${handle}@basemail.ai`);
718742
const memoHex = Array.from(memo).map(b => b.toString(16).padStart(2, '0')).join('');
719743

720-
// Encode transfer + append memo as extra calldata
721-
const transferData = encodeFunctionData({
722-
abi: ERC20_ABI,
723-
functionName: 'transfer',
724-
args: [recipientWallet as `0x${string}`, amountRaw],
725-
});
726-
const fullData = (transferData + memoHex) as `0x${string}`;
727-
728744
const hash = await writeContractAsync({
729-
address: BASE_SEPOLIA_USDC,
745+
address: net.usdc,
730746
abi: ERC20_ABI,
731747
functionName: 'transfer',
732748
args: [recipientWallet as `0x${string}`, amountRaw],
733-
chainId: BASE_SEPOLIA_CHAIN_ID,
734-
// Use raw data with memo appended
749+
chainId: net.chainId,
735750
dataSuffix: `0x${memoHex}` as `0x${string}`,
736751
});
737752
setTxHash(hash);
738753

739754
// 3. Wait for confirmation + send verified payment email
740755
setStatus('sending_email');
741-
const emailTo = handle.startsWith('0x') ? `${handle}@basemail.ai` : `${handle}@basemail.ai`;
756+
const emailTo = `${handle}@basemail.ai`;
757+
const networkLabel = network === 'base-mainnet' ? 'Base' : 'Base Sepolia (testnet)';
742758
const res = await apiFetch('/api/send', auth.token, {
743759
method: 'POST',
744760
body: JSON.stringify({
745761
to: emailTo,
746762
subject: `USDC Payment: $${parseFloat(amount).toFixed(2)}`,
747-
body: `You received a payment of ${parseFloat(amount).toFixed(2)} USDC on Base Sepolia (testnet).\n\nTransaction: https://sepolia.basescan.org/tx/${hash}\n\nSent via BaseMail.ai`,
748-
usdc_payment: { tx_hash: hash, amount: parseFloat(amount).toFixed(2) },
763+
body: `You received a payment of ${parseFloat(amount).toFixed(2)} USDC on ${networkLabel}.\n\nTransaction: ${net.explorer}/tx/${hash}\n\nSent via BaseMail.ai`,
764+
usdc_payment: { tx_hash: hash, amount: parseFloat(amount).toFixed(2), network },
749765
}),
750766
});
751767
const data = await res.json();
@@ -764,7 +780,7 @@ function UsdcSendModal({ auth, onClose }: { auth: AuthState; onClose: () => void
764780
<div className="flex items-center justify-between mb-4">
765781
<div>
766782
<h3 className="text-lg font-bold">Send USDC</h3>
767-
<span className="text-[10px] text-purple-400 bg-purple-900/30 px-2 py-0.5 rounded">Base Sepolia Testnet</span>
783+
<span className={`text-[10px] ${USDC_NET_CONFIG[network].badgeColor} px-2 py-0.5 rounded`}>{USDC_NET_CONFIG[network].badge}</span>
768784
</div>
769785
<button onClick={onClose} className="text-gray-500 hover:text-white text-xl">&times;</button>
770786
</div>
@@ -778,7 +794,7 @@ function UsdcSendModal({ auth, onClose }: { auth: AuthState; onClose: () => void
778794
</p>
779795
{txHash && (
780796
<a
781-
href={`https://sepolia.basescan.org/tx/${txHash}`}
797+
href={`${USDC_NET_CONFIG[network].explorer}/tx/${txHash}`}
782798
target="_blank"
783799
rel="noopener noreferrer"
784800
className="text-purple-400 hover:text-purple-300 text-xs underline"
@@ -814,6 +830,33 @@ function UsdcSendModal({ auth, onClose }: { auth: AuthState; onClose: () => void
814830
)}
815831
</div>
816832

833+
{/* Network Selector */}
834+
<div className="mb-4">
835+
<label className="text-gray-400 text-xs mb-1 block">Network</label>
836+
<div className="flex gap-2">
837+
<button
838+
onClick={() => setNetwork('base-mainnet')}
839+
className={`flex-1 py-2 rounded-lg text-sm font-medium transition border ${
840+
network === 'base-mainnet'
841+
? 'bg-green-900/40 border-green-500 text-green-400'
842+
: 'bg-base-dark border-gray-700 text-gray-500 hover:border-gray-500'
843+
}`}
844+
>
845+
💰 Base Mainnet
846+
</button>
847+
<button
848+
onClick={() => setNetwork('base-sepolia')}
849+
className={`flex-1 py-2 rounded-lg text-sm font-medium transition border ${
850+
network === 'base-sepolia'
851+
? 'bg-purple-900/40 border-purple-500 text-purple-400'
852+
: 'bg-base-dark border-gray-700 text-gray-500 hover:border-gray-500'
853+
}`}
854+
>
855+
🧪 Testnet
856+
</button>
857+
</div>
858+
</div>
859+
817860
{/* Amount */}
818861
<div className="mb-4">
819862
<label className="text-gray-400 text-xs mb-1 block">Amount (USDC)</label>
@@ -828,7 +871,8 @@ function UsdcSendModal({ auth, onClose }: { auth: AuthState; onClose: () => void
828871

829872
{/* Info */}
830873
<div className="bg-base-dark rounded-lg p-3 mb-4 text-xs text-gray-500 space-y-1">
831-
<p>Payment goes directly to recipient's wallet on Base Sepolia.</p>
874+
<p>Payment goes directly to recipient's wallet on {USDC_NET_CONFIG[network].label}.</p>
875+
{network === 'base-mainnet' && <p className="text-yellow-400">⚠️ This sends real USDC. Double-check the recipient.</p>}
832876
<p>A verified payment email will be sent automatically.</p>
833877
<p className="text-purple-400">On-chain memo: basemail:{recipient || '...'}@basemail.ai</p>
834878
</div>
@@ -837,9 +881,11 @@ function UsdcSendModal({ auth, onClose }: { auth: AuthState; onClose: () => void
837881
<button
838882
onClick={handleSend}
839883
disabled={!recipientWallet || !amount || parseFloat(amount) <= 0 || status !== 'idle' && status !== 'error'}
840-
className="w-full bg-purple-600 text-white py-3 rounded-lg font-medium hover:bg-purple-500 transition disabled:opacity-50"
884+
className={`w-full text-white py-3 rounded-lg font-medium transition disabled:opacity-50 ${
885+
network === 'base-mainnet' ? 'bg-green-600 hover:bg-green-500' : 'bg-purple-600 hover:bg-purple-500'
886+
}`}
841887
>
842-
{status === 'switching' ? 'Switching to Base Sepolia...'
888+
{status === 'switching' ? `Switching to ${USDC_NET_CONFIG[network].label}...`
843889
: status === 'transferring' ? 'Confirm in wallet...'
844890
: status === 'confirming' ? 'Waiting for confirmation...'
845891
: status === 'sending_email' ? 'Sending payment email...'
@@ -1355,12 +1401,12 @@ function EmailDetail({ auth }: { auth: AuthState }) {
13551401
<span className="text-2xl">$</span>
13561402
<div>
13571403
<div className="text-green-400 font-bold text-lg">{email.usdc_amount} USDC</div>
1358-
<div className="text-green-600 text-xs">Verified Payment (Base Sepolia Testnet)</div>
1404+
<div className="text-green-600 text-xs">Verified USDC Payment</div>
13591405
</div>
13601406
</div>
13611407
{email.usdc_tx && (
13621408
<a
1363-
href={`https://sepolia.basescan.org/tx/${email.usdc_tx}`}
1409+
href={`${email.usdc_network === 'base-mainnet' ? 'https://basescan.org' : 'https://sepolia.basescan.org'}/tx/${email.usdc_tx}`}
13641410
target="_blank"
13651411
rel="noopener noreferrer"
13661412
className="text-green-500 hover:text-green-400 text-xs underline"

worker/src/db/schema.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ CREATE TABLE IF NOT EXISTS emails (
2525
size INTEGER DEFAULT 0,
2626
read INTEGER DEFAULT 0,
2727
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
28+
usdc_amount TEXT,
29+
usdc_tx TEXT,
30+
usdc_network TEXT,
2831
FOREIGN KEY (handle) REFERENCES accounts(handle)
2932
);
3033

worker/src/routes/send.ts

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,46 @@
11
import { Hono } from 'hono';
22
import { EmailMessage } from 'cloudflare:email';
33
import { createMimeMessage } from 'mimetext';
4-
import { createPublicClient, http, parseAbi, type Hex } from 'viem';
5-
import { baseSepolia } from 'viem/chains';
4+
import { createPublicClient, http, parseAbi, type Hex, type Chain } from 'viem';
5+
import { base, baseSepolia } from 'viem/chains';
66
import { AppBindings } from '../types';
77
import { authMiddleware } from '../auth';
88

9-
// ── USDC Hackathon (TESTNET ONLY — Base Sepolia) ──
10-
const BASE_SEPOLIA_RPC = 'https://sepolia.base.org';
11-
const BASE_SEPOLIA_USDC = '0x036CbD53842c5426634e7929541eC2318f3dCF7e';
9+
// ── USDC Network Configs ──
10+
const USDC_NETWORKS: Record<string, { chain: Chain; rpc: string; usdc: string; label: string; explorer: string }> = {
11+
'base-mainnet': {
12+
chain: base,
13+
rpc: 'https://mainnet.base.org',
14+
usdc: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
15+
label: 'Base Mainnet',
16+
explorer: 'https://basescan.org',
17+
},
18+
'base-sepolia': {
19+
chain: baseSepolia,
20+
rpc: 'https://sepolia.base.org',
21+
usdc: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
22+
label: 'Base Sepolia (Testnet)',
23+
explorer: 'https://sepolia.basescan.org',
24+
},
25+
};
1226
const USDC_TRANSFER_ABI = parseAbi(['event Transfer(address indexed from, address indexed to, uint256 value)']);
1327

1428
export const sendRoutes = new Hono<AppBindings>();
1529

1630
sendRoutes.use('/*', authMiddleware());
1731

32+
// Auto-migrate: add usdc columns if missing
33+
let migrated = false;
34+
sendRoutes.use('/*', async (c, next) => {
35+
if (!migrated) {
36+
migrated = true;
37+
for (const col of ['usdc_amount TEXT', 'usdc_tx TEXT', 'usdc_network TEXT']) {
38+
try { await c.env.DB.prepare(`ALTER TABLE emails ADD COLUMN ${col}`).run(); } catch {}
39+
}
40+
}
41+
await next();
42+
});
43+
1844
// ── Email Signature (appended for free-tier users) ──
1945
const TEXT_SIGNATURE = `\n\n--\nSent via BaseMail.ai — Email Identity for AI Agents on Base\nhttps://basemail.ai`;
2046

@@ -29,6 +55,7 @@ interface Attachment {
2955
interface UsdcPayment {
3056
tx_hash: string;
3157
amount: string; // human-readable e.g. "10.00"
58+
network?: string; // 'base-mainnet' | 'base-sepolia' (default: 'base-sepolia' for backward compat)
3259
}
3360

3461
/**
@@ -86,12 +113,18 @@ sendRoutes.post('/', async (c) => {
86113
}
87114
}
88115

89-
// ── USDC Payment Verification (Base Sepolia TESTNET) ──
90-
let verifiedUsdc: { amount: string; tx_hash: string } | null = null;
116+
// ── USDC Payment Verification (supports Base Mainnet + Base Sepolia) ──
117+
let verifiedUsdc: { amount: string; tx_hash: string; network: string } | null = null;
91118

92119
if (usdc_payment?.tx_hash) {
120+
const networkKey = usdc_payment.network || 'base-sepolia';
121+
const netConfig = USDC_NETWORKS[networkKey];
122+
if (!netConfig) {
123+
return c.json({ error: `Unsupported USDC network: ${networkKey}. Use 'base-mainnet' or 'base-sepolia'` }, 400);
124+
}
125+
93126
try {
94-
const client = createPublicClient({ chain: baseSepolia, transport: http(BASE_SEPOLIA_RPC) });
127+
const client = createPublicClient({ chain: netConfig.chain, transport: http(netConfig.rpc) });
95128
const receipt = await client.waitForTransactionReceipt({
96129
hash: usdc_payment.tx_hash as Hex,
97130
timeout: 15_000,
@@ -103,7 +136,7 @@ sendRoutes.post('/', async (c) => {
103136

104137
// Parse Transfer events from USDC contract
105138
const transferLog = receipt.logs.find(
106-
(log) => log.address.toLowerCase() === BASE_SEPOLIA_USDC.toLowerCase() && log.topics[0] === '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
139+
(log) => log.address.toLowerCase() === netConfig.usdc.toLowerCase() && log.topics[0] === '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
107140
);
108141

109142
if (!transferLog || !transferLog.topics[1] || !transferLog.topics[2]) {
@@ -133,7 +166,7 @@ sendRoutes.post('/', async (c) => {
133166
return c.json({ error: 'USDC recipient does not match email recipient wallet' }, 400);
134167
}
135168

136-
verifiedUsdc = { amount: humanAmount, tx_hash: usdc_payment.tx_hash };
169+
verifiedUsdc = { amount: humanAmount, tx_hash: usdc_payment.tx_hash, network: networkKey };
137170
} catch (e: any) {
138171
return c.json({ error: `USDC verification failed: ${e.message}` }, 400);
139172
}
@@ -178,7 +211,7 @@ sendRoutes.post('/', async (c) => {
178211
if (verifiedUsdc) {
179212
msg.setHeader('X-BaseMail-USDC-Payment', `${verifiedUsdc.amount} USDC`);
180213
msg.setHeader('X-BaseMail-USDC-TxHash', verifiedUsdc.tx_hash);
181-
msg.setHeader('X-BaseMail-USDC-Network', 'Base Sepolia (Testnet)');
214+
msg.setHeader('X-BaseMail-USDC-Network', USDC_NETWORKS[verifiedUsdc.network]?.label || verifiedUsdc.network);
182215
}
183216

184217
// Reply headers
@@ -257,8 +290,8 @@ sendRoutes.post('/', async (c) => {
257290
await c.env.EMAIL_STORE.put(inboxR2Key, rawMime);
258291

259292
await c.env.DB.prepare(
260-
`INSERT INTO emails (id, handle, folder, from_addr, to_addr, subject, snippet, r2_key, size, read, created_at, usdc_amount, usdc_tx)
261-
VALUES (?, ?, 'inbox', ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`
293+
`INSERT INTO emails (id, handle, folder, from_addr, to_addr, subject, snippet, r2_key, size, read, created_at, usdc_amount, usdc_tx, usdc_network)
294+
VALUES (?, ?, 'inbox', ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`
262295
).bind(
263296
inboxEmailId,
264297
recipientHandle,
@@ -271,6 +304,7 @@ sendRoutes.post('/', async (c) => {
271304
now,
272305
verifiedUsdc?.amount || null,
273306
verifiedUsdc?.tx_hash || null,
307+
verifiedUsdc?.network || null,
274308
).run();
275309
} else {
276310
// ── External sending (paid, costs 1 credit) ──
@@ -372,8 +406,8 @@ sendRoutes.post('/', async (c) => {
372406
await c.env.EMAIL_STORE.put(sentR2Key, rawMime);
373407

374408
await c.env.DB.prepare(
375-
`INSERT INTO emails (id, handle, folder, from_addr, to_addr, subject, snippet, r2_key, size, read, created_at, usdc_amount, usdc_tx)
376-
VALUES (?, ?, 'sent', ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`
409+
`INSERT INTO emails (id, handle, folder, from_addr, to_addr, subject, snippet, r2_key, size, read, created_at, usdc_amount, usdc_tx, usdc_network)
410+
VALUES (?, ?, 'sent', ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)`
377411
).bind(
378412
emailId,
379413
auth.handle,
@@ -386,6 +420,7 @@ sendRoutes.post('/', async (c) => {
386420
now,
387421
verifiedUsdc?.amount || null,
388422
verifiedUsdc?.tx_hash || null,
423+
verifiedUsdc?.network || null,
389424
).run();
390425

391426
// Auto-resolve attention bond if replying to a bonded email
@@ -426,7 +461,7 @@ sendRoutes.post('/', async (c) => {
426461
verified: true,
427462
amount: verifiedUsdc.amount,
428463
tx_hash: verifiedUsdc.tx_hash,
429-
network: 'Base Sepolia (Testnet)',
464+
network: USDC_NETWORKS[verifiedUsdc.network]?.label || verifiedUsdc.network,
430465
},
431466
} : {}),
432467
});

0 commit comments

Comments
 (0)