feat: Transak credit card → USDC fiat on-ramp for rFunds TBFF

Add Transak widget integration so users can fund flows with a credit card.
Server receives webhook on order completion and deposits USDC into the flow
via the existing Flow Service API. Includes HMAC signature verification
when TRANSAK_WEBHOOK_SECRET is configured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-02 18:04:53 -08:00
parent 46c2a0b035
commit 34c96b5a45
4 changed files with 144 additions and 0 deletions

View File

@ -1405,6 +1405,14 @@ class FolkFundsApp extends HTMLElement {
<select class="editor-select" data-field="sourceType">
${["card", "safe_wallet", "ridentity", "unconfigured"].map((t) => `<option value="${t}" ${d.sourceType === t ? "selected" : ""}>${t}</option>`).join("")}
</select></div>`;
if (d.sourceType === "card") {
html += `<div class="editor-field" style="margin-top:12px">
<button class="editor-btn fund-card-btn" data-action="fund-with-card"
style="width:100%;padding:10px;background:#6366f1;color:white;border:none;border-radius:8px;cursor:pointer;font-weight:600">
Fund with Card
</button>
</div>`;
}
html += this.renderAllocEditor("Target Allocations", d.targetAllocations);
return html;
}
@ -1476,6 +1484,62 @@ class FolkFundsApp extends HTMLElement {
return html;
}
private async openTransakWidget(flowId: string, walletAddress: string) {
// Fetch Transak config from server
let apiKey = "STAGING_KEY";
let env = "STAGING";
try {
const base = this.space ? `/s/${this.space}/rfunds` : "/rfunds";
const res = await fetch(`${base}/api/transak/config`);
if (res.ok) {
const cfg = await res.json();
apiKey = cfg.apiKey || apiKey;
env = cfg.environment || env;
}
} catch { /* use defaults */ }
const baseUrl = env === "PRODUCTION"
? "https://global.transak.com"
: "https://global-stg.transak.com";
const params = new URLSearchParams({
apiKey,
environment: env,
cryptoCurrencyCode: "USDC",
network: "base",
defaultCryptoCurrency: "USDC",
walletAddress,
partnerOrderId: flowId,
themeColor: "6366f1",
hideMenu: "true",
});
const modal = document.createElement("div");
modal.id = "transak-modal";
modal.style.cssText = `position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;`;
modal.innerHTML = `
<div style="position:relative;width:450px;height:680px;border-radius:16px;overflow:hidden;background:#1e1e2e">
<button id="transak-close" style="position:absolute;top:8px;right:12px;z-index:10;
background:none;border:none;color:white;font-size:24px;cursor:pointer">&times;</button>
<iframe src="${baseUrl}?${params}" style="width:100%;height:100%;border:none"
allow="camera;microphone;payment" sandbox="allow-scripts allow-same-origin allow-popups allow-forms"></iframe>
</div>`;
document.body.appendChild(modal);
modal.querySelector("#transak-close")!.addEventListener("click", () => modal.remove());
modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); });
const handler = (e: MessageEvent) => {
if (e.data?.event_id === "TRANSAK_ORDER_SUCCESSFUL") {
console.log("[Transak] Order successful:", e.data.data);
modal.remove();
window.removeEventListener("message", handler);
}
};
window.addEventListener("message", handler);
}
private attachEditorListeners(panel: HTMLElement, node: FlowNode) {
// Close button
panel.querySelector('[data-editor-action="close"]')?.addEventListener("click", () => this.closeEditor());
@ -1499,6 +1563,18 @@ class FolkFundsApp extends HTMLElement {
this.updateSufficiencyBadge();
});
});
// Fund with Card button (source nodes with sourceType "card")
panel.querySelector('[data-action="fund-with-card"]')?.addEventListener("click", () => {
const flowId = this.flowId || this.getAttribute("flow-id") || "";
const sourceData = node.data as SourceNodeData;
const walletAddress = sourceData.walletAddress || "";
if (!walletAddress) {
alert("Configure a wallet address first (use rIdentity passkey or enter manually)");
return;
}
this.openTransakWidget(flowId, walletAddress);
});
}
// ─── Node CRUD ────────────────────────────────────────

View File

@ -82,6 +82,7 @@ export interface SourceNodeData {
walletAddress?: string;
chainId?: number;
safeAddress?: string;
transakOrderId?: string;
[key: string]: unknown;
}

View File

@ -145,6 +145,70 @@ routes.get("/api/flows/:flowId/transactions", async (c) => {
return c.json(await res.json(), res.status as any);
});
// ─── Transak fiat on-ramp ────────────────────────────────
routes.get("/api/transak/config", (c) => {
return c.json({
apiKey: process.env.TRANSAK_API_KEY || "STAGING_KEY",
environment: process.env.TRANSAK_ENV || "STAGING",
});
});
routes.post("/api/transak/webhook", async (c) => {
const rawBody = await c.req.text();
// HMAC verification — if TRANSAK_WEBHOOK_SECRET is set, validate signature
const webhookSecret = process.env.TRANSAK_WEBHOOK_SECRET;
if (webhookSecret) {
const signature = c.req.header("x-transak-signature") || "";
const { createHmac } = await import("crypto");
const expected = createHmac("sha256", webhookSecret).update(rawBody).digest("hex");
if (signature !== expected) {
console.error("[Transak] Invalid webhook signature");
return c.json({ error: "Invalid signature" }, 401);
}
}
const body = JSON.parse(rawBody);
const { webhookData } = body;
// Ack non-completion events (Transak sends multiple status updates)
if (!webhookData || webhookData.status !== "COMPLETED") {
return c.json({ ok: true });
}
const { partnerOrderId, cryptoAmount, cryptocurrency, network } = webhookData;
if (!partnerOrderId || cryptocurrency !== "USDC" || !network?.toLowerCase().includes("base")) {
return c.json({ error: "Invalid webhook data" }, 400);
}
// partnerOrderId format: "flowId" or "flowId:funnelId"
const [flowId, funnelId] = partnerOrderId.split(":");
if (!flowId) return c.json({ error: "Missing flowId in partnerOrderId" }, 400);
// Convert crypto amount to USDC units (6 decimals)
const amountUnits = Math.round(parseFloat(cryptoAmount) * 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: "transak",
...(funnelId ? { funnelId } : {}),
}),
});
if (!res.ok) {
console.error(`[Transak] Deposit failed: ${await res.text()}`);
return c.json({ error: "Deposit failed" }, 500);
}
console.log(`[Transak] Deposit OK: flow=${flowId} amount=${amountUnits} USDC`);
return c.json({ ok: true });
});
// ─── Space-flow association endpoints ────────────────────
routes.post("/api/space-flows", async (c) => {

View File

@ -69,6 +69,9 @@ RUNPOD_API_KEY|AI/MI|RunPod API key
X402_PAY_TO|payments|Payment recipient address
MAILCOW_API_KEY|EncryptID|Mailcow admin API key
ENCRYPTID_DEMO_SPACES|EncryptID|Comma-separated demo space slugs
TRANSAK_API_KEY|rFunds|Transak widget API key (public, scoped to app)
TRANSAK_WEBHOOK_SECRET|rFunds|Transak webhook HMAC secret for signature verification
TRANSAK_ENV|rFunds|Transak environment: STAGING or PRODUCTION (default: STAGING)
"
ADDED=0