feat(rflows+rwallet): diverse relay providers — on-ramp abstraction, configurable RPC, Pimlico bundler

- On-ramp provider interface + registry (transak > coinbase > ramp priority)
- TransakOnrampAdapter, CoinbaseOnrampAdapter, RampOnrampAdapter
- Provider-agnostic user-onramp endpoint with dynamic /api/onramp/config
- Coinbase + Ramp Network webhook handlers
- Frontend provider dropdown in Fund modal with multi-provider postMessage
- Configurable RPC: env var overrides (RPC_BASE etc), Alchemy auto-construct, public fallback
- Pimlico ERC-4337 bundler client + auth-gated routes (submit/send/receipt)
- Remove hardcoded Transak staging creds from entrypoint.sh (use Infisical)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 16:10:44 -07:00
parent c4717e3c68
commit b3c449f54e
10 changed files with 577 additions and 117 deletions

View File

@ -77,9 +77,4 @@ if [ -n "$INFISICAL_AI_CLIENT_ID" ] && [ -n "$INFISICAL_AI_CLIENT_SECRET" ]; the
fi
fi
# TEMP: Use staging credentials until production IP is whitelisted with Transak
export TRANSAK_API_KEY="1880fd5c-c1a8-48ff-8581-cd5977538ecd"
export TRANSAK_SECRET="+CXXAOb7FNX4MTYPvRBJOw=="
export TRANSAK_ENV="STAGING"
exec "$@"

View File

@ -3546,11 +3546,19 @@ class FolkFlowsApp extends HTMLElement {
<input id="fund-email" type="email" placeholder="friend@example.com"
style="${inputStyle}"/>
</label>
<label style="display:block;margin-bottom:20px">
<label style="display:block;margin-bottom:12px">
<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary)">Label (optional)</span>
<input id="fund-label" type="text" placeholder="Coffee Fund"
style="${inputStyle}"/>
</label>
<label style="display:block;margin-bottom:20px">
<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary)">Payment Provider</span>
<select id="fund-provider" style="${inputStyle}">
<option value="transak">Transak</option>
<option value="coinbase">Coinbase</option>
<option value="ramp">Ramp Network</option>
</select>
</label>
<div style="display:flex;gap:8px">
<button id="fund-cancel" style="flex:1;padding:10px;border:1px solid var(--rflows-modal-border);border-radius:8px;background:none;color:var(--rs-text-secondary);cursor:pointer">Cancel</button>
<button id="fund-submit" style="flex:1;padding:10px;border:none;border-radius:8px;background:var(--rs-primary);color:white;font-weight:600;cursor:pointer">Pay with Card</button>
@ -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) =>
`<option value="${p.id}">${p.name}</option>`
).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.");

View File

@ -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<OnrampSessionResult> {
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' };
}
}

View File

@ -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<OnrampSessionResult>;
}

View File

@ -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;
}

View File

@ -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<any> {
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<GasEstimate> {
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<PaymasterResult> {
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<string> {
return await this.rpc('eth_sendUserOperation', [userOp, entryPoint]);
}
async getUserOperationReceipt(userOpHash: string): Promise<UserOperationReceipt | null> {
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<UserOperation> {
// 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;
}
}

View File

@ -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<OnrampSessionResult> {
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' };
}
}

View File

@ -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<OnrampSessionResult> {
const apiKey = process.env.TRANSAK_API_KEY;
if (!apiKey) throw new Error('Transak not configured');
const widgetParams: Record<string, string> = {
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' };
}
}

View File

@ -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<string>(); // 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<string> {
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<string, string>): Promise<string> {
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<FlowsDoc>(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<string, string> = {
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 {

View File

@ -137,7 +137,7 @@ function safeApiBase(prefix: string): string {
}
// ── Public RPC endpoints for EOA balance lookups ──
const RPC_URLS: Record<string, string> = {
const DEFAULT_RPC_URLS: Record<string, string> = {
"1": "https://eth.llamarpc.com",
"10": "https://mainnet.optimism.io",
"100": "https://rpc.gnosischain.com",
@ -152,6 +152,44 @@ const RPC_URLS: Record<string, string> = {
"84532": "https://sepolia.base.org",
};
// Chain ID → env var name fragment + Alchemy subdomain (for auto-construct)
const CHAIN_ENV_NAMES: Record<string, { envName: string; alchemySlug?: string }> = {
"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<string, { name: string; symbol: string; decimals: number }> = {
"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 };