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:
parent
c4717e3c68
commit
b3c449f54e
|
|
@ -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 "$@"
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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' };
|
||||
}
|
||||
}
|
||||
|
|
@ -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' };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue