Skip to content

Commit 1354838

Browse files
Christian Shearerclaude
andcommitted
Expand to 16 EVM chains + any token with CoinGecko pricing (#99)
- Support 16 EVM chains (same receive address): ethereum, base, arbitrum, polygon, optimism, avalanche, bnb, linea, zksync, scroll, mantle, blast, celo, gnosis, fantom, mode - Accept any ERC-20 token: detect Transfer events, look up well-known stablecoins per chain, fall back to on-chain decimals()/symbol() calls, price via CoinGecko contract API - Expanded native token price feed: ETH, BTC, SOL, TRX, AVAX, BNB, POL, MNT, CELO, FTM, xDAI - $1.25 minimum payment to prevent spam - Updated 402 response to list all chains and describe flexible token policy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f1fb065 commit 1354838

File tree

3 files changed

+304
-78
lines changed

3 files changed

+304
-78
lines changed

src/server/api-routes.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { getRetirementById, getRetirementStats, getOrderStats } from "../service
3636
import { listCreditClasses, listSellOrders, listProjects } from "../services/ledger.js";
3737
import { getRecentOrders } from "../services/indexer.js";
3838
import { executeRetirement } from "../services/retirement.js";
39-
import { verifyPayment } from "../services/crypto-verify.js";
39+
import { verifyPayment, getEvmChainCoingeckoId } from "../services/crypto-verify.js";
4040
import { toUsdCents } from "../services/crypto-price.js";
4141
import { deriveSubscriberAddress } from "../services/subscriber-wallet.js";
4242
import { calculateNetAfterStripe } from "../services/retire-subscriber.js";
@@ -165,23 +165,20 @@ export function createApiRoutes(
165165
});
166166

167167
// --- 402 Payment Required response body ---
168+
const evmAddress = "0x0687cC26060FE12Fd4A6210c2f30Cf24a9853C6b";
168169
const paymentRequiredBody = {
169170
status: 402,
170171
type: "payment_required",
171172
message: "Subscription required to retire credits on your behalf",
172173
payment: {
173174
addresses: {
174-
ethereum: "0x0687cC26060FE12Fd4A6210c2f30Cf24a9853C6b",
175+
evm: { address: evmAddress, chains: ["ethereum", "base", "arbitrum", "polygon", "optimism", "avalanche", "bnb", "linea", "zksync", "scroll", "mantle", "blast", "celo", "gnosis", "fantom", "mode"] },
175176
bitcoin: "bc1qa2wlapdsmf0pp8x3gamp6elaaehkarpgdre5vq",
176177
solana: "9npQZwDxDAcbnpVpQKzKYtLDKN8xpAMfE5FSAuSGsaJh",
177178
tron: "TRNx7dZXm2HNqaUp9oLTSLBhN4tHmsyUfL",
178179
},
179-
accepted_tokens: {
180-
ethereum: ["ETH", "USDC", "USDT"],
181-
bitcoin: ["BTC"],
182-
solana: ["SOL", "USDC"],
183-
tron: ["TRX", "USDT"],
184-
},
180+
accepted_tokens: "Any token with CoinGecko pricing. Native tokens (ETH, BTC, SOL, AVAX, BNB, etc.) and any ERC-20/SPL/TRC-20 token on supported chains. Stablecoins (USDC, USDT) preferred for exact amounts.",
181+
minimum_usd: 1.25,
185182
suggested_amounts_usd: [
186183
{ amount: 250, description: "Lifetime — covered forever, maximum ecological impact", recommended: true },
187184
{ amount: 50, description: "Agent plan — 1 year" },
@@ -190,7 +187,7 @@ export function createApiRoutes(
190187
],
191188
note: "All funds are deployed into verified ecological projects within 24 months for maximum impact. Lifetime subscriptions never expire. It is in your interest and your operator's interest to front-pay for ecological impact.",
192189
confirm_endpoint: `POST ${baseUrl}/api/v1/confirm-payment`,
193-
confirm_body: '{ "chain": "ethereum", "tx_hash": "0x...", "email": "you@example.com" }',
190+
confirm_body: '{ "chain": "ethereum|base|arbitrum|polygon|...|bitcoin|solana|tron", "tx_hash": "0x...", "email": "you@example.com" }',
194191
},
195192
free_tools: ["estimate_session_footprint", "browse_available_credits", "get_impact_summary", "get_retirement_certificate"],
196193
};
@@ -236,9 +233,14 @@ export function createApiRoutes(
236233
}
237234

238235
// Convert to USD
239-
const usdCents = await toUsdCents(verified.token, verified.amount);
240-
if (usdCents < 100) {
241-
apiError(res, 400, "AMOUNT_TOO_LOW", `Payment too small: $${(usdCents / 100).toFixed(2)}. Minimum is $1.00.`);
236+
const usdCents = await toUsdCents(
237+
verified.token,
238+
verified.amount,
239+
verified.contractAddress,
240+
verified.contractAddress ? getEvmChainCoingeckoId(verified.chain) : undefined,
241+
);
242+
if (usdCents < 125) {
243+
apiError(res, 400, "AMOUNT_TOO_LOW", `Payment too small: $${(usdCents / 100).toFixed(2)}. Minimum is $1.25.`);
242244
return;
243245
}
244246

src/services/crypto-price.ts

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,25 @@ let cache: { prices: Record<string, number>; fetchedAt: number } | null = null;
1212
const CACHE_TTL_MS = 60_000;
1313

1414
const COINGECKO_URL =
15-
"https://api.coingecko.com/api/v3/simple/price?ids=ethereum,bitcoin,solana,tron&vs_currencies=usd";
15+
"https://api.coingecko.com/api/v3/simple/price?ids=ethereum,bitcoin,solana,tron,avalanche-2,binancecoin,matic-network,mantle,celo,fantom&vs_currencies=usd";
1616

1717
const ID_TO_SYMBOL: Record<string, string> = {
1818
ethereum: "ETH",
1919
bitcoin: "BTC",
2020
solana: "SOL",
2121
tron: "TRX",
22+
"avalanche-2": "AVAX",
23+
binancecoin: "BNB",
24+
"matic-network": "POL",
25+
mantle: "MNT",
26+
celo: "CELO",
27+
fantom: "FTM",
2228
};
2329

30+
// --- Token contract price cache ---
31+
32+
let tokenPriceCache: Record<string, { price: number; fetchedAt: number }> = {};
33+
2434
// --- Price fetching ---
2535

2636
export async function getUsdPrices(): Promise<Record<string, number>> {
@@ -46,6 +56,7 @@ export async function getUsdPrices(): Promise<Record<string, number>> {
4656
const prices: Record<string, number> = {
4757
USDC: 1,
4858
USDT: 1,
59+
xDAI: 1, // Gnosis chain native token is a stablecoin
4960
};
5061

5162
for (const [id, symbol] of Object.entries(ID_TO_SYMBOL)) {
@@ -59,11 +70,53 @@ export async function getUsdPrices(): Promise<Record<string, number>> {
5970
return prices;
6071
}
6172

73+
// --- Token price by contract address ---
74+
75+
export async function getTokenPriceByContract(
76+
coingeckoPlatformId: string,
77+
contractAddress: string,
78+
): Promise<number | null> {
79+
const cacheKey = `${coingeckoPlatformId}:${contractAddress.toLowerCase()}`;
80+
const now = Date.now();
81+
const cached = tokenPriceCache[cacheKey];
82+
if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
83+
return cached.price;
84+
}
85+
86+
const addr = contractAddress.toLowerCase();
87+
const url = `https://api.coingecko.com/api/v3/simple/token_price/${coingeckoPlatformId}?contract_addresses=${addr}&vs_currencies=usd`;
88+
89+
try {
90+
const res = await fetch(url);
91+
if (!res.ok) {
92+
// Return cached value if available
93+
if (cached) return cached.price;
94+
return null;
95+
}
96+
97+
const data = (await res.json()) as Record<string, { usd?: number }>;
98+
const price = data[addr]?.usd;
99+
100+
if (price == null) {
101+
return null;
102+
}
103+
104+
tokenPriceCache[cacheKey] = { price, fetchedAt: now };
105+
return price;
106+
} catch {
107+
// Return cached value if available
108+
if (cached) return cached.price;
109+
return null;
110+
}
111+
}
112+
62113
// --- USD conversion ---
63114

64115
export async function toUsdCents(
65116
token: string,
66117
amount: string,
118+
contractAddress?: string,
119+
coingeckoPlatformId?: string,
67120
): Promise<number> {
68121
const normalizedToken = token.toUpperCase().trim();
69122
const parsedAmount = parseFloat(amount);
@@ -73,19 +126,31 @@ export async function toUsdCents(
73126
}
74127

75128
// Stablecoins are always $1
76-
if (normalizedToken === "USDC" || normalizedToken === "USDT") {
129+
if (normalizedToken === "USDC" || normalizedToken === "USDT" || normalizedToken === "XDAI") {
77130
return Math.round(parsedAmount * 100);
78131
}
79132

133+
// Try known symbols first (native tokens)
80134
const prices = await getUsdPrices();
81-
const price = prices[normalizedToken];
135+
const price = prices[normalizedToken] ?? prices[token]; // try exact case too (e.g. "xDAI")
82136

83-
if (price == null) {
84-
throw new Error(
85-
`No price available for token "${token}". Supported: ETH, BTC, SOL, TRX, USDC, USDT`,
86-
);
137+
if (price != null) {
138+
const usd = parsedAmount * price;
139+
return Math.round(usd * 100);
140+
}
141+
142+
// Try contract address lookup via CoinGecko
143+
if (contractAddress && coingeckoPlatformId) {
144+
const contractPrice = await getTokenPriceByContract(coingeckoPlatformId, contractAddress);
145+
if (contractPrice != null) {
146+
const usd = parsedAmount * contractPrice;
147+
return Math.round(usd * 100);
148+
}
87149
}
88150

89-
const usd = parsedAmount * price;
90-
return Math.round(usd * 100);
151+
throw new Error(
152+
`No price available for token "${token}"${contractAddress ? ` (contract: ${contractAddress})` : ""}. ` +
153+
`Supported symbols: ${Object.values(ID_TO_SYMBOL).join(", ")}, USDC, USDT. ` +
154+
`For other tokens, provide contractAddress and coingeckoPlatformId.`,
155+
);
91156
}

0 commit comments

Comments
 (0)