@@ -3561,8 +3569,19 @@ class FolkFlowsApp extends HTMLElement {
const amountInput = modal.querySelector("#fund-amount") as HTMLInputElement;
const emailInput = modal.querySelector("#fund-email") as HTMLInputElement;
const labelInput = modal.querySelector("#fund-label") as HTMLInputElement;
+ const providerSelect = modal.querySelector("#fund-provider") as HTMLSelectElement;
emailInput.focus();
+ // Populate provider dropdown from server config
+ const base = this.getApiBase();
+ fetch(`${base}/api/onramp/config`).then((r) => r.json()).then((cfg: any) => {
+ if (cfg.available && Array.isArray(cfg.available)) {
+ providerSelect.innerHTML = cfg.available.map((p: any) =>
+ `
`
+ ).join("");
+ }
+ }).catch(() => { /* keep static defaults */ });
+
const cleanup = (value: { email: string; amount: number; label: string; provider: string } | null) => { modal.remove(); resolve(value); };
const submit = () => {
@@ -3570,7 +3589,7 @@ class FolkFlowsApp extends HTMLElement {
const amount = parseFloat(amountInput.value) || 0;
if (!email || !email.includes("@")) { emailInput.style.borderColor = "red"; return; }
if (amount <= 0) { amountInput.style.borderColor = "red"; return; }
- cleanup({ email, amount, label: labelInput.value.trim(), provider: "transak" });
+ cleanup({ email, amount, label: labelInput.value.trim(), provider: providerSelect.value });
};
modal.querySelector("#fund-cancel")!.addEventListener("click", () => cleanup(null));
@@ -3754,7 +3773,7 @@ class FolkFlowsApp extends HTMLElement {
/**
* Open Transak on-ramp widget in an iframe modal.
*/
- private openWidgetModal(url: string) {
+ private openWidgetModal(url: string, provider: string = 'transak') {
const showClaimMessage = () => {
document.getElementById("onramp-modal")?.remove();
const successModal = document.createElement("div");
@@ -3797,8 +3816,13 @@ class FolkFlowsApp extends HTMLElement {
modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); });
const handler = (e: MessageEvent) => {
- if (e.data?.event_id === "TRANSAK_ORDER_SUCCESSFUL") {
- console.log("[OnRamp] Transak order successful:", e.data.data);
+ const d = e.data;
+ const isSuccess =
+ (provider === 'transak' && d?.event_id === "TRANSAK_ORDER_SUCCESSFUL") ||
+ (provider === 'coinbase' && d?.eventName === "success") ||
+ (provider === 'ramp' && d?.type === "PURCHASE_CREATED");
+ if (isSuccess) {
+ console.log(`[OnRamp] ${provider} order successful:`, d);
window.removeEventListener("message", handler);
modal.remove();
showClaimMessage();
@@ -3860,7 +3884,7 @@ class FolkFlowsApp extends HTMLElement {
}
// Open on-ramp widget
- this.openWidgetModal(data.widgetUrl);
+ this.openWidgetModal(data.widgetUrl, data.provider || details.provider);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[UserOnRamp] Error:", msg, err);
@@ -3922,7 +3946,7 @@ class FolkFlowsApp extends HTMLElement {
this.scheduleSave();
// Open on-ramp widget
- this.openWidgetModal(data.widgetUrl);
+ this.openWidgetModal(data.widgetUrl, data.provider || details.provider);
} catch (err) {
console.error("[QuickFund] Error:", err);
alert("Failed to start funding. Check console for details.");
diff --git a/modules/rflows/lib/coinbase-onramp.ts b/modules/rflows/lib/coinbase-onramp.ts
index a27b849..57fa1fc 100644
--- a/modules/rflows/lib/coinbase-onramp.ts
+++ b/modules/rflows/lib/coinbase-onramp.ts
@@ -10,6 +10,7 @@
import { SignJWT } from 'jose';
import { randomBytes, createPrivateKey } from 'crypto';
+import type { OnrampProvider, OnrampSessionRequest, OnrampSessionResult } from './onramp-provider';
const CDP_API_HOST = 'api.developer.coinbase.com';
const TOKEN_PATH = '/onramp/v1/token';
@@ -154,3 +155,40 @@ export class CoinbaseOnrampProvider {
return { sessionToken, onrampUrl };
}
}
+
+/**
+ * Adapter wrapping CoinbaseOnrampProvider behind the generic OnrampProvider interface.
+ */
+export class CoinbaseOnrampAdapter implements OnrampProvider {
+ id = 'coinbase' as const;
+ name = 'Coinbase';
+
+ private provider: CoinbaseOnrampProvider | null = null;
+
+ isAvailable(): boolean {
+ return !!(process.env.COINBASE_API_KEY_ID && process.env.COINBASE_API_KEY_SECRET && process.env.COINBASE_PROJECT_ID);
+ }
+
+ private getProvider(): CoinbaseOnrampProvider {
+ if (!this.provider) {
+ this.provider = new CoinbaseOnrampProvider({
+ apiKeyId: process.env.COINBASE_API_KEY_ID!,
+ apiKeySecret: process.env.COINBASE_API_KEY_SECRET!,
+ projectId: process.env.COINBASE_PROJECT_ID!,
+ });
+ }
+ return this.provider;
+ }
+
+ async createSession(req: OnrampSessionRequest): Promise
{
+ const result = await this.getProvider().createSession({
+ walletAddress: req.walletAddress,
+ fiatAmount: req.fiatAmount,
+ fiatCurrency: req.fiatCurrency,
+ partnerUserRef: req.sessionId,
+ redirectUrl: req.returnUrl,
+ });
+
+ return { widgetUrl: result.onrampUrl, provider: 'coinbase' };
+ }
+}
diff --git a/modules/rflows/lib/onramp-provider.ts b/modules/rflows/lib/onramp-provider.ts
new file mode 100644
index 0000000..b6f6294
--- /dev/null
+++ b/modules/rflows/lib/onramp-provider.ts
@@ -0,0 +1,29 @@
+/**
+ * On-ramp provider abstraction — common interface for fiat-to-crypto providers.
+ *
+ * Each provider implements this interface to produce a widget URL
+ * that the frontend can embed in an iframe for card purchases.
+ */
+
+export type OnrampProviderId = 'transak' | 'coinbase' | 'ramp';
+
+export interface OnrampSessionRequest {
+ walletAddress: string;
+ email: string;
+ fiatAmount: number;
+ fiatCurrency: string;
+ sessionId: string;
+ returnUrl?: string;
+}
+
+export interface OnrampSessionResult {
+ widgetUrl: string;
+ provider: OnrampProviderId;
+}
+
+export interface OnrampProvider {
+ id: OnrampProviderId;
+ name: string;
+ isAvailable(): boolean;
+ createSession(req: OnrampSessionRequest): Promise;
+}
diff --git a/modules/rflows/lib/onramp-registry.ts b/modules/rflows/lib/onramp-registry.ts
new file mode 100644
index 0000000..d53126c
--- /dev/null
+++ b/modules/rflows/lib/onramp-registry.ts
@@ -0,0 +1,31 @@
+/**
+ * On-ramp provider registry — discovers available providers and selects defaults.
+ *
+ * Priority order: transak > coinbase > ramp (first available wins as default).
+ */
+
+import type { OnrampProvider, OnrampProviderId } from './onramp-provider';
+import { TransakOnrampAdapter } from './transak-onramp';
+import { CoinbaseOnrampAdapter } from './coinbase-onramp';
+import { RampOnrampAdapter } from './ramp-onramp';
+
+const providers: OnrampProvider[] = [
+ new TransakOnrampAdapter(),
+ new CoinbaseOnrampAdapter(),
+ new RampOnrampAdapter(),
+];
+
+export function getAvailableProviders(): { id: OnrampProviderId; name: string }[] {
+ return providers
+ .filter((p) => p.isAvailable())
+ .map((p) => ({ id: p.id, name: p.name }));
+}
+
+export function getProvider(id: OnrampProviderId): OnrampProvider | null {
+ const p = providers.find((p) => p.id === id);
+ return p && p.isAvailable() ? p : null;
+}
+
+export function getDefaultProvider(): OnrampProvider | null {
+ return providers.find((p) => p.isAvailable()) || null;
+}
diff --git a/modules/rflows/lib/pimlico.ts b/modules/rflows/lib/pimlico.ts
new file mode 100644
index 0000000..d2bfd25
--- /dev/null
+++ b/modules/rflows/lib/pimlico.ts
@@ -0,0 +1,151 @@
+/**
+ * Pimlico ERC-4337 bundler client — wraps Pimlico's JSON-RPC v2 API.
+ *
+ * Provides gas estimation, paymaster sponsorship, UserOperation submission,
+ * and receipt polling for Account Abstraction transactions.
+ *
+ * Docs: https://docs.pimlico.io/
+ */
+
+const PIMLICO_BASE = 'https://api.pimlico.io/v2';
+
+export interface UserOperation {
+ sender: string;
+ nonce: string;
+ initCode: string;
+ callData: string;
+ callGasLimit?: string;
+ verificationGasLimit?: string;
+ preVerificationGas?: string;
+ maxFeePerGas?: string;
+ maxPriorityFeePerGas?: string;
+ paymasterAndData?: string;
+ signature?: string;
+}
+
+export interface GasEstimate {
+ callGasLimit: string;
+ verificationGasLimit: string;
+ preVerificationGas: string;
+ maxFeePerGas: string;
+ maxPriorityFeePerGas: string;
+}
+
+export interface PaymasterResult {
+ paymasterAndData: string;
+ callGasLimit: string;
+ verificationGasLimit: string;
+ preVerificationGas: string;
+}
+
+export interface UserOperationReceipt {
+ userOpHash: string;
+ sender: string;
+ nonce: string;
+ actualGasCost: string;
+ actualGasUsed: string;
+ success: boolean;
+ receipt: {
+ transactionHash: string;
+ blockNumber: string;
+ status: string;
+ };
+}
+
+export class PimlicoClient {
+ private apiKey: string;
+ private chainId: number;
+
+ constructor(opts: { apiKey: string; chainId: number }) {
+ this.apiKey = opts.apiKey;
+ this.chainId = opts.chainId;
+ }
+
+ private get endpoint(): string {
+ return `${PIMLICO_BASE}/${this.chainId}/rpc?apikey=${this.apiKey}`;
+ }
+
+ private async rpc(method: string, params: any[]): Promise {
+ const res = await fetch(this.endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
+ signal: AbortSignal.timeout(15000),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`Pimlico RPC error (${res.status}): ${text}`);
+ }
+
+ const data = await res.json() as any;
+ if (data.error) {
+ throw new Error(`Pimlico ${method} error: ${data.error.message || JSON.stringify(data.error)}`);
+ }
+ return data.result;
+ }
+
+ async estimateGas(userOp: UserOperation, entryPoint: string): Promise {
+ const result = await this.rpc('eth_estimateUserOperationGas', [userOp, entryPoint]);
+ // Pimlico also returns fee data via pimlico_getUserOperationGasPrice
+ const fees = await this.rpc('pimlico_getUserOperationGasPrice', []);
+ return {
+ callGasLimit: result.callGasLimit,
+ verificationGasLimit: result.verificationGasLimit,
+ preVerificationGas: result.preVerificationGas,
+ maxFeePerGas: fees.fast.maxFeePerGas,
+ maxPriorityFeePerGas: fees.fast.maxPriorityFeePerGas,
+ };
+ }
+
+ async getPaymasterData(
+ userOp: UserOperation,
+ entryPoint: string,
+ opts?: { sponsorshipPolicyId?: string },
+ ): Promise {
+ const params: any[] = [userOp, entryPoint, { type: 'payg' }];
+ if (opts?.sponsorshipPolicyId) {
+ params[2] = { sponsorshipPolicyId: opts.sponsorshipPolicyId };
+ }
+ return await this.rpc('pm_sponsorUserOperation', params);
+ }
+
+ async sendUserOperation(userOp: UserOperation, entryPoint: string): Promise {
+ return await this.rpc('eth_sendUserOperation', [userOp, entryPoint]);
+ }
+
+ async getUserOperationReceipt(userOpHash: string): Promise {
+ return await this.rpc('eth_getUserOperationReceipt', [userOpHash]);
+ }
+
+ /**
+ * Full prepare flow: estimate gas → get paymaster sponsorship → return complete UserOp.
+ */
+ async prepareUserOperation(
+ userOp: UserOperation,
+ entryPoint: string,
+ opts?: { sponsorshipPolicyId?: string },
+ ): Promise {
+ // 1. Estimate gas limits + fee data
+ const gas = await this.estimateGas(userOp, entryPoint);
+
+ const prepared: UserOperation = {
+ ...userOp,
+ callGasLimit: gas.callGasLimit,
+ verificationGasLimit: gas.verificationGasLimit,
+ preVerificationGas: gas.preVerificationGas,
+ maxFeePerGas: gas.maxFeePerGas,
+ maxPriorityFeePerGas: gas.maxPriorityFeePerGas,
+ };
+
+ // 2. Get paymaster sponsorship data
+ const pm = await this.getPaymasterData(prepared, entryPoint, opts);
+ prepared.paymasterAndData = pm.paymasterAndData;
+ // Paymaster may override gas limits
+ if (pm.callGasLimit) prepared.callGasLimit = pm.callGasLimit;
+ if (pm.verificationGasLimit) prepared.verificationGasLimit = pm.verificationGasLimit;
+ if (pm.preVerificationGas) prepared.preVerificationGas = pm.preVerificationGas;
+
+ return prepared;
+ }
+}
diff --git a/modules/rflows/lib/ramp-onramp.ts b/modules/rflows/lib/ramp-onramp.ts
new file mode 100644
index 0000000..c2e6a22
--- /dev/null
+++ b/modules/rflows/lib/ramp-onramp.ts
@@ -0,0 +1,32 @@
+/**
+ * Ramp Network on-ramp adapter — URL-based widget, no server-side session needed.
+ *
+ * Docs: https://docs.ramp.network/
+ */
+
+import type { OnrampProvider, OnrampSessionRequest, OnrampSessionResult } from './onramp-provider';
+
+export class RampOnrampAdapter implements OnrampProvider {
+ id = 'ramp' as const;
+ name = 'Ramp Network';
+
+ isAvailable(): boolean {
+ return !!process.env.RAMP_API_KEY;
+ }
+
+ async createSession(req: OnrampSessionRequest): Promise {
+ const hostApiKey = process.env.RAMP_API_KEY;
+ if (!hostApiKey) throw new Error('Ramp Network not configured');
+
+ const url = new URL('https://app.ramp.network/');
+ url.searchParams.set('hostApiKey', hostApiKey);
+ url.searchParams.set('userAddress', req.walletAddress);
+ url.searchParams.set('swapAsset', 'BASE_USDC');
+ url.searchParams.set('fiatValue', req.fiatAmount.toString());
+ url.searchParams.set('fiatCurrency', req.fiatCurrency.toUpperCase());
+ if (req.email) url.searchParams.set('userEmailAddress', req.email);
+ if (req.returnUrl) url.searchParams.set('finalUrl', req.returnUrl);
+
+ return { widgetUrl: url.toString(), provider: 'ramp' };
+ }
+}
diff --git a/modules/rflows/lib/transak-onramp.ts b/modules/rflows/lib/transak-onramp.ts
new file mode 100644
index 0000000..fe55757
--- /dev/null
+++ b/modules/rflows/lib/transak-onramp.ts
@@ -0,0 +1,38 @@
+/**
+ * Transak on-ramp adapter — wraps shared/transak.ts utilities
+ * behind the OnrampProvider interface.
+ */
+
+import type { OnrampProvider, OnrampSessionRequest, OnrampSessionResult } from './onramp-provider';
+import { createTransakWidgetUrl } from '../../../shared/transak';
+
+export class TransakOnrampAdapter implements OnrampProvider {
+ id = 'transak' as const;
+ name = 'Transak';
+
+ isAvailable(): boolean {
+ return !!(process.env.TRANSAK_API_KEY && process.env.TRANSAK_SECRET);
+ }
+
+ async createSession(req: OnrampSessionRequest): Promise {
+ const apiKey = process.env.TRANSAK_API_KEY;
+ if (!apiKey) throw new Error('Transak not configured');
+
+ const widgetParams: Record = {
+ apiKey,
+ referrerDomain: 'rspace.online',
+ cryptoCurrencyCode: 'USDC',
+ network: 'base',
+ defaultCryptoCurrency: 'USDC',
+ walletAddress: req.walletAddress,
+ partnerOrderId: `user-${req.sessionId}`,
+ email: req.email,
+ themeColor: '6366f1',
+ hideMenu: 'true',
+ };
+ if (req.returnUrl) widgetParams.redirectURL = req.returnUrl;
+
+ const widgetUrl = await createTransakWidgetUrl(widgetParams);
+ return { widgetUrl, provider: 'transak' };
+ }
+}
diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts
index 5ac66b5..6cd3622 100644
--- a/modules/rflows/mod.ts
+++ b/modules/rflows/mod.ts
@@ -18,95 +18,17 @@ import { OpenfortProvider } from './lib/openfort';
import { boardDocId, createTaskItem } from '../rtasks/schemas';
import type { BoardDoc } from '../rtasks/schemas';
import type { OutcomeNodeData } from './lib/types';
+import { getAvailableProviders, getProvider, getDefaultProvider } from './lib/onramp-registry';
+import type { OnrampProviderId } from './lib/onramp-provider';
+import { PimlicoClient } from './lib/pimlico';
let _syncServer: SyncServer | null = null;
let _openfort: OpenfortProvider | null = null;
+let _pimlico: PimlicoClient | null = null;
const _completedOutcomes = new Set(); // space:outcomeId — dedup for watcher
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
-// ─── Transak API-based widget URL (mandatory since migration) ────
-let _transakAccessToken: string | null = null;
-let _transakTokenExpiry = 0;
-
-async function getTransakAccessToken(): Promise {
- if (_transakAccessToken && Date.now() < _transakTokenExpiry) return _transakAccessToken;
-
- const apiKey = process.env.TRANSAK_API_KEY;
- const apiSecret = process.env.TRANSAK_SECRET;
- if (!apiKey || !apiSecret) throw new Error("Transak credentials not configured");
-
- const env = process.env.TRANSAK_ENV || 'PRODUCTION';
- const baseUrl = env === 'PRODUCTION'
- ? 'https://api.transak.com'
- : 'https://api-stg.transak.com';
-
- const res = await fetch(`${baseUrl}/partners/api/v2/refresh-token`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'api-secret': apiSecret,
- },
- body: JSON.stringify({ apiKey }),
- });
-
- if (!res.ok) {
- const text = await res.text();
- throw new Error(`Transak token refresh failed (${res.status}): ${text}`);
- }
-
- const data = await res.json() as any;
- _transakAccessToken = data.data?.accessToken || data.accessToken;
- if (!_transakAccessToken) throw new Error("No accessToken in Transak response");
-
- // Cache for 6 days (tokens valid for 7)
- _transakTokenExpiry = Date.now() + 6 * 24 * 60 * 60 * 1000;
- console.log('[rflows] Transak access token refreshed');
- return _transakAccessToken;
-}
-
-async function createTransakWidgetUrl(params: Record): Promise {
- const accessToken = await getTransakAccessToken();
- const env = process.env.TRANSAK_ENV || 'PRODUCTION';
- const gatewayUrl = env === 'PRODUCTION'
- ? 'https://api-gateway.transak.com'
- : 'https://api-gateway-stg.transak.com';
-
- const res = await fetch(`${gatewayUrl}/api/v2/auth/session`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'access-token': accessToken,
- },
- body: JSON.stringify({ widgetParams: params }),
- });
-
- if (!res.ok) {
- const text = await res.text();
- // If token expired, clear cache and retry once
- if (res.status === 401 || res.status === 403) {
- _transakAccessToken = null;
- _transakTokenExpiry = 0;
- const retryToken = await getTransakAccessToken();
- const retry = await fetch(`${gatewayUrl}/api/v2/auth/session`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'access-token': retryToken,
- },
- body: JSON.stringify({ widgetParams: params }),
- });
- if (!retry.ok) throw new Error(`Transak widget URL failed on retry (${retry.status}): ${await retry.text()}`);
- const retryData = await retry.json() as any;
- return retryData.data?.widgetUrl;
- }
- throw new Error(`Transak widget URL failed (${res.status}): ${text}`);
- }
-
- const data = await res.json() as any;
- return data.data?.widgetUrl;
-}
-
function ensureDoc(space: string): FlowsDoc {
const docId = flowsDocId(space);
let doc = _syncServer!.getDoc(docId);
@@ -289,7 +211,11 @@ routes.post("/api/flows/user-onramp", async (c) => {
if (!_openfort) return c.json({ error: "Openfort not configured" }, 503);
- const provider = 'transak';
+ // Resolve on-ramp provider: use requested, else first available
+ const onramp = reqProvider
+ ? getProvider(reqProvider as OnrampProviderId)
+ : getDefaultProvider();
+ if (!onramp) return c.json({ error: "No on-ramp provider available" }, 503);
// 1. Find or create Openfort smart wallet for this user (one wallet per email)
const wallet = await _openfort.findOrCreateWallet(email, {
@@ -299,26 +225,17 @@ routes.post("/api/flows/user-onramp", async (c) => {
const sessionId = crypto.randomUUID();
- // 2. Transak: create widget URL via API
- const transakApiKey = process.env.TRANSAK_API_KEY;
- if (!transakApiKey) return c.json({ error: "Transak not configured" }, 503);
-
- const widgetParams: Record = {
- apiKey: transakApiKey,
- referrerDomain: 'rspace.online',
- cryptoCurrencyCode: 'USDC',
- network: 'base',
- defaultCryptoCurrency: 'USDC',
+ // 2. Create on-ramp session via provider
+ const { widgetUrl, provider } = await onramp.createSession({
walletAddress: wallet.address,
- partnerOrderId: `user-${sessionId}`,
email,
- themeColor: '6366f1',
- hideMenu: 'true',
- };
- if (returnUrl) widgetParams.redirectURL = returnUrl;
- const widgetUrl = await createTransakWidgetUrl(widgetParams);
+ fiatAmount,
+ fiatCurrency,
+ sessionId,
+ returnUrl,
+ });
- console.log(`[rflows] On-ramp session created: provider=transak session=${sessionId} wallet=${wallet.address}`);
+ console.log(`[rflows] On-ramp session created: provider=${provider} session=${sessionId} wallet=${wallet.address}`);
// Non-fatal side-effect: create fund claim → sends email via EncryptID
const encryptidServiceKey = process.env.ENCRYPTID_SERVICE_KEY;
@@ -368,9 +285,10 @@ routes.post("/api/flows/user-onramp", async (c) => {
// ─── On-ramp config ──────────────────────────────────────
routes.get("/api/onramp/config", (c) => {
+ const available = getAvailableProviders();
return c.json({
- provider: "transak",
- available: ["transak"],
+ provider: available[0]?.id || null,
+ available,
});
});
@@ -440,6 +358,160 @@ routes.post("/api/transak/webhook", async (c) => {
return c.json({ ok: true });
});
+// ─── ERC-4337 UserOperation routes (Pimlico bundler) ─────
+
+const ENTRY_POINT = '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789'; // v0.6
+
+routes.post("/api/flows/submit-userop", async (c) => {
+ const token = extractToken(c.req.raw.headers);
+ if (!token) return c.json({ error: "Authentication required" }, 401);
+ try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
+
+ if (!_pimlico) return c.json({ error: "Pimlico bundler not configured" }, 503);
+
+ const userOp = await c.req.json();
+ try {
+ const prepared = await _pimlico.prepareUserOperation(userOp, ENTRY_POINT);
+ return c.json({ success: true, userOp: prepared, entryPoint: ENTRY_POINT });
+ } catch (err) {
+ console.error("[pimlico] prepare failed:", err);
+ const msg = err instanceof Error ? err.message : String(err);
+ return c.json({ error: msg }, 500);
+ }
+});
+
+routes.post("/api/flows/send-userop", async (c) => {
+ const token = extractToken(c.req.raw.headers);
+ if (!token) return c.json({ error: "Authentication required" }, 401);
+ try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
+
+ if (!_pimlico) return c.json({ error: "Pimlico bundler not configured" }, 503);
+
+ const { userOp } = await c.req.json();
+ if (!userOp) return c.json({ error: "userOp required" }, 400);
+
+ try {
+ const hash = await _pimlico.sendUserOperation(userOp, ENTRY_POINT);
+ return c.json({ success: true, userOpHash: hash });
+ } catch (err) {
+ console.error("[pimlico] send failed:", err);
+ const msg = err instanceof Error ? err.message : String(err);
+ return c.json({ error: msg }, 500);
+ }
+});
+
+routes.get("/api/flows/userop/:hash", async (c) => {
+ const token = extractToken(c.req.raw.headers);
+ if (!token) return c.json({ error: "Authentication required" }, 401);
+ try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
+
+ if (!_pimlico) return c.json({ error: "Pimlico bundler not configured" }, 503);
+
+ const hash = c.req.param("hash");
+ try {
+ const receipt = await _pimlico.getUserOperationReceipt(hash);
+ return c.json({ receipt });
+ } catch (err) {
+ console.error("[pimlico] receipt failed:", err);
+ const msg = err instanceof Error ? err.message : String(err);
+ return c.json({ error: msg }, 500);
+ }
+});
+
+// ─── Coinbase webhook ────────────────────────────────────
+
+routes.post("/api/coinbase/webhook", async (c) => {
+ let body: any;
+ try { body = await c.req.json(); } catch { return c.json({ error: "Invalid JSON" }, 400); }
+
+ // HMAC verification
+ const webhookSecret = process.env.COINBASE_WEBHOOK_SECRET;
+ if (webhookSecret) {
+ const signature = c.req.header("x-cc-webhook-signature") || "";
+ const { createHmac } = await import("crypto");
+ const expected = createHmac("sha256", webhookSecret).update(JSON.stringify(body)).digest("hex");
+ if (signature !== expected) {
+ console.error("[Coinbase] Invalid webhook signature");
+ return c.json({ error: "Invalid signature" }, 401);
+ }
+ }
+
+ const { event } = body;
+ if (!event || event.type !== "charge:confirmed") return c.json({ ok: true });
+
+ const metadata = event.data?.metadata || {};
+ const { flowId, funnelId } = metadata;
+ const pricing = event.data?.pricing?.local;
+ if (!flowId || !pricing) return c.json({ error: "Missing flowId or pricing" }, 400);
+
+ const resolvedFunnelId = funnelId || process.env.FUNNEL_ID || "";
+ if (!resolvedFunnelId) return c.json({ error: "Missing funnelId" }, 400);
+
+ const amountUnits = Math.round(parseFloat(pricing.amount) * 1e6).toString();
+
+ const depositUrl = `${FLOW_SERVICE_URL}/api/flows/${flowId}/deposit`;
+ const res = await fetch(depositUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ amount: amountUnits, source: "card", funnelId: resolvedFunnelId }),
+ });
+
+ if (!res.ok) {
+ console.error(`[Coinbase] Deposit failed: ${await res.text()}`);
+ return c.json({ error: "Deposit failed" }, 500);
+ }
+
+ console.log(`[Coinbase] Deposit OK: flow=${flowId} amount=${amountUnits} USDC`);
+ return c.json({ ok: true });
+});
+
+// ─── Ramp Network webhook ────────────────────────────────
+
+routes.post("/api/ramp/webhook", async (c) => {
+ let body: any;
+ try { body = await c.req.json(); } catch { return c.json({ error: "Invalid JSON" }, 400); }
+
+ // HMAC verification
+ const webhookSecret = process.env.RAMP_WEBHOOK_SECRET;
+ if (webhookSecret) {
+ const signature = c.req.header("x-body-signature") || "";
+ const { createHmac } = await import("crypto");
+ const expected = createHmac("sha256", webhookSecret).update(JSON.stringify(body)).digest("hex");
+ if (signature !== expected) {
+ console.error("[Ramp] Invalid webhook signature");
+ return c.json({ error: "Invalid signature" }, 401);
+ }
+ }
+
+ if (body.type !== "RELEASED" || body.asset?.symbol !== "USDC") return c.json({ ok: true });
+
+ const purchaseViewToken = body.purchaseViewToken || "";
+ // Ramp uses receiverAddress metadata or custom purchase field for flowId
+ const flowId = body.metadata?.flowId || body.flowId;
+ const funnelId = body.metadata?.funnelId || body.funnelId;
+ if (!flowId) return c.json({ error: "Missing flowId" }, 400);
+
+ const resolvedFunnelId = funnelId || process.env.FUNNEL_ID || "";
+ if (!resolvedFunnelId) return c.json({ error: "Missing funnelId" }, 400);
+
+ const amountUnits = Math.round(parseFloat(body.cryptoAmount || "0") * 1e6).toString();
+
+ const depositUrl = `${FLOW_SERVICE_URL}/api/flows/${flowId}/deposit`;
+ const res = await fetch(depositUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ amount: amountUnits, source: "card", funnelId: resolvedFunnelId }),
+ });
+
+ if (!res.ok) {
+ console.error(`[Ramp] Deposit failed: ${await res.text()}`);
+ return c.json({ error: "Deposit failed" }, 500);
+ }
+
+ console.log(`[Ramp] Deposit OK: flow=${flowId} amount=${amountUnits} USDC`);
+ return c.json({ ok: true });
+});
+
// ─── Space-flow association endpoints ────────────────────
routes.post("/api/space-flows", async (c) => {
@@ -593,6 +665,18 @@ export const flowsModule: RSpaceModule = {
console.log('[rflows] Openfort provider initialized');
}
+ if (process.env.PIMLICO_API_KEY) {
+ _pimlico = new PimlicoClient({
+ apiKey: process.env.PIMLICO_API_KEY,
+ chainId: 8453, // Base mainnet
+ });
+ console.log('[rflows] Pimlico bundler initialized');
+ }
+
+ // Log available on-ramp providers
+ const onrampProviders = getAvailableProviders();
+ console.log(`[rflows] On-ramp providers: ${onrampProviders.map((p) => p.id).join(', ') || 'none'}`)
+
// Watch for completed outcomes in flow docs → auto-create DONE tasks
_syncServer.registerWatcher(':flows:data', (docId, doc) => {
try {
diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts
index 5327074..88538c3 100644
--- a/modules/rwallet/mod.ts
+++ b/modules/rwallet/mod.ts
@@ -137,7 +137,7 @@ function safeApiBase(prefix: string): string {
}
// ── Public RPC endpoints for EOA balance lookups ──
-const RPC_URLS: Record = {
+const DEFAULT_RPC_URLS: Record = {
"1": "https://eth.llamarpc.com",
"10": "https://mainnet.optimism.io",
"100": "https://rpc.gnosischain.com",
@@ -152,6 +152,44 @@ const RPC_URLS: Record = {
"84532": "https://sepolia.base.org",
};
+// Chain ID → env var name fragment + Alchemy subdomain (for auto-construct)
+const CHAIN_ENV_NAMES: Record = {
+ "1": { envName: "ETHEREUM", alchemySlug: "eth-mainnet" },
+ "10": { envName: "OPTIMISM", alchemySlug: "opt-mainnet" },
+ "137": { envName: "POLYGON", alchemySlug: "polygon-mainnet" },
+ "8453": { envName: "BASE", alchemySlug: "base-mainnet" },
+ "42161": { envName: "ARBITRUM", alchemySlug: "arb-mainnet" },
+ "100": { envName: "GNOSIS" },
+ "42220": { envName: "CELO" },
+ "43114": { envName: "AVALANCHE" },
+ "56": { envName: "BSC" },
+ "324": { envName: "ZKSYNC" },
+ "11155111": { envName: "SEPOLIA" },
+ "84532": { envName: "BASE_SEPOLIA" },
+};
+
+/**
+ * Resolve the RPC URL for a given chain, with env var overrides and Alchemy auto-construct.
+ * Priority: RPC_{CHAIN} env > Alchemy auto-construct > default public RPC.
+ */
+export function getRpcUrl(chainId: string): string | undefined {
+ // 1. Explicit env var override (e.g. RPC_BASE, RPC_ETHEREUM)
+ const chainEnv = CHAIN_ENV_NAMES[chainId];
+ if (chainEnv) {
+ const envOverride = process.env[`RPC_${chainEnv.envName}`];
+ if (envOverride) return envOverride;
+ }
+
+ // 2. Alchemy auto-construct if API key is set
+ const alchemyKey = process.env.ALCHEMY_API_KEY;
+ if (alchemyKey && chainEnv?.alchemySlug) {
+ return `https://${chainEnv.alchemySlug}.g.alchemy.com/v2/${alchemyKey}`;
+ }
+
+ // 3. Default public RPC
+ return DEFAULT_RPC_URLS[chainId];
+}
+
const NATIVE_TOKENS: Record = {
"1": { name: "Ether", symbol: "ETH", decimals: 18 },
"10": { name: "Ether", symbol: "ETH", decimals: 18 },
@@ -373,7 +411,7 @@ routes.get("/api/eoa/detect/:address", async (c) => {
await Promise.allSettled(
getChains(includeTestnets).map(async ([chainId, info]) => {
- const rpcUrl = RPC_URLS[chainId];
+ const rpcUrl = getRpcUrl(chainId);
if (!rpcUrl) return;
try {
const balHex = await rpcCall(rpcUrl, "eth_getBalance", [address, "latest"]);
@@ -401,7 +439,7 @@ routes.get("/api/eoa/:chainId/:address/balances", async (c) => {
const chainId = c.req.param("chainId");
const address = validateAddress(c);
if (!address) return c.json({ error: "Invalid Ethereum address" }, 400);
- const rpcUrl = RPC_URLS[chainId];
+ const rpcUrl = getRpcUrl(chainId);
if (!rpcUrl) return c.json({ error: "Unsupported chain" }, 400);
const nativeToken = NATIVE_TOKENS[chainId] || { name: "ETH", symbol: "ETH", decimals: 18 };