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:
parent
46c2a0b035
commit
34c96b5a45
|
|
@ -1405,6 +1405,14 @@ class FolkFundsApp extends HTMLElement {
|
||||||
<select class="editor-select" data-field="sourceType">
|
<select class="editor-select" data-field="sourceType">
|
||||||
${["card", "safe_wallet", "ridentity", "unconfigured"].map((t) => `<option value="${t}" ${d.sourceType === t ? "selected" : ""}>${t}</option>`).join("")}
|
${["card", "safe_wallet", "ridentity", "unconfigured"].map((t) => `<option value="${t}" ${d.sourceType === t ? "selected" : ""}>${t}</option>`).join("")}
|
||||||
</select></div>`;
|
</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);
|
html += this.renderAllocEditor("Target Allocations", d.targetAllocations);
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
@ -1476,6 +1484,62 @@ class FolkFundsApp extends HTMLElement {
|
||||||
return html;
|
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">×</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) {
|
private attachEditorListeners(panel: HTMLElement, node: FlowNode) {
|
||||||
// Close button
|
// Close button
|
||||||
panel.querySelector('[data-editor-action="close"]')?.addEventListener("click", () => this.closeEditor());
|
panel.querySelector('[data-editor-action="close"]')?.addEventListener("click", () => this.closeEditor());
|
||||||
|
|
@ -1499,6 +1563,18 @@ class FolkFundsApp extends HTMLElement {
|
||||||
this.updateSufficiencyBadge();
|
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 ────────────────────────────────────────
|
// ─── Node CRUD ────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ export interface SourceNodeData {
|
||||||
walletAddress?: string;
|
walletAddress?: string;
|
||||||
chainId?: number;
|
chainId?: number;
|
||||||
safeAddress?: string;
|
safeAddress?: string;
|
||||||
|
transakOrderId?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,70 @@ routes.get("/api/flows/:flowId/transactions", async (c) => {
|
||||||
return c.json(await res.json(), res.status as any);
|
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 ────────────────────
|
// ─── Space-flow association endpoints ────────────────────
|
||||||
|
|
||||||
routes.post("/api/space-flows", async (c) => {
|
routes.post("/api/space-flows", async (c) => {
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ RUNPOD_API_KEY|AI/MI|RunPod API key
|
||||||
X402_PAY_TO|payments|Payment recipient address
|
X402_PAY_TO|payments|Payment recipient address
|
||||||
MAILCOW_API_KEY|EncryptID|Mailcow admin API key
|
MAILCOW_API_KEY|EncryptID|Mailcow admin API key
|
||||||
ENCRYPTID_DEMO_SPACES|EncryptID|Comma-separated demo space slugs
|
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
|
ADDED=0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue