fix(transak): use direct widget URL instead of broken gateway API

The Transak gateway session API consistently returns 401 despite valid
access tokens. Switch to direct URL construction (query params on
global.transak.com) which Transak still supports and is simpler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 20:32:23 -07:00
parent 1f4b28aee1
commit 96b77278d4
3 changed files with 34 additions and 99 deletions

View File

@ -1368,26 +1368,22 @@ routes.post("/api/payments/:id/transak-session", async (c) => {
const networkMap: Record<number, string> = { 8453: 'base', 84532: 'base', 1: 'ethereum' }; const networkMap: Record<number, string> = { 8453: 'base', 84532: 'base', 1: 'ethereum' };
try { const widgetUrl = createTransakWidgetUrl({
const widgetUrl = await createTransakWidgetUrl({ apiKey: transakApiKey,
apiKey: transakApiKey, referrerDomain: 'rspace.online',
referrerDomain: 'rspace.online', cryptoCurrencyCode: p.token,
cryptoCurrencyCode: p.token, network: networkMap[p.chainId] || 'base',
network: networkMap[p.chainId] || 'base', defaultCryptoCurrency: p.token,
defaultCryptoCurrency: p.token, walletAddress: p.recipientAddress,
walletAddress: p.recipientAddress, disableWalletAddressForm: 'true',
disableWalletAddressForm: 'true', cryptoAmount: p.amount,
cryptoAmount: p.amount, partnerOrderId: `pay-${paymentId}`,
partnerOrderId: `pay-${paymentId}`, email,
email, themeColor: '6366f1',
themeColor: '6366f1', hideMenu: 'true',
hideMenu: 'true', });
});
return c.json({ widgetUrl }); return c.json({ widgetUrl });
} catch (err) {
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
}
}); });
function paymentToResponse(p: PaymentRequestMeta) { function paymentToResponse(p: PaymentRequestMeta) {
@ -1465,7 +1461,7 @@ routes.post("/api/group-buys", async (c) => {
_syncServer!.setDoc(docId, doc); _syncServer!.setDoc(docId, doc);
const host = c.req.header('host') || 'rspace.online'; const host = c.req.header('host') || 'rspace.online';
const shareUrl = `https://${host}/${space}/rcart/buy/${buyId}`; const shareUrl = `https://${host}/${space}/rcart/group-buy/${buyId}`;
return c.json({ id: buyId, shareUrl }, 201); return c.json({ id: buyId, shareUrl }, 201);
}); });
@ -1575,7 +1571,7 @@ routes.post("/api/group-buys/:id/pledge", async (c) => {
}); });
// ── Page route: group buy page ── // ── Page route: group buy page ──
routes.get("/buy/:id", (c) => { routes.get("/group-buy/:id", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
const buyId = c.req.param("id"); const buyId = c.req.param("id");
return c.html(renderShell({ return c.html(renderShell({
@ -1728,7 +1724,9 @@ export const cartModule: RSpaceModule = {
acceptsFeeds: ["economic", "data"], acceptsFeeds: ["economic", "data"],
outputPaths: [ outputPaths: [
{ path: "carts", name: "Carts", icon: "🛒", description: "Group shopping carts" }, { path: "carts", name: "Carts", icon: "🛒", description: "Group shopping carts" },
{ path: "products", name: "Products", icon: "🛍️", description: "Print-on-demand product catalog" }, { path: "catalog", name: "Catalog", icon: "🛍️", description: "Print-on-demand product catalog" },
{ path: "orders", name: "Orders", icon: "📦", description: "Order history and fulfillment tracking" }, { path: "orders", name: "Orders", icon: "📦", description: "Order history and fulfillment tracking" },
{ path: "payments", name: "Payments", icon: "💳", description: "Payment requests and invoices" },
{ path: "group-buys", name: "Group Buys", icon: "👥", description: "Volume discount group purchasing campaigns" },
], ],
}; };

View File

@ -11,7 +11,7 @@ export class TransakOnrampAdapter implements OnrampProvider {
name = 'Transak'; name = 'Transak';
isAvailable(): boolean { isAvailable(): boolean {
return !!(process.env.TRANSAK_API_KEY && process.env.TRANSAK_SECRET); return !!process.env.TRANSAK_API_KEY;
} }
async createSession(req: OnrampSessionRequest): Promise<OnrampSessionResult> { async createSession(req: OnrampSessionRequest): Promise<OnrampSessionResult> {
@ -32,7 +32,7 @@ export class TransakOnrampAdapter implements OnrampProvider {
}; };
if (req.returnUrl) widgetParams.redirectURL = req.returnUrl; if (req.returnUrl) widgetParams.redirectURL = req.returnUrl;
const widgetUrl = await createTransakWidgetUrl(widgetParams); const widgetUrl = createTransakWidgetUrl(widgetParams);
return { widgetUrl, provider: 'transak' }; return { widgetUrl, provider: 'transak' };
} }
} }

View File

@ -1,87 +1,24 @@
/** /**
* Transak API utilities shared across rFlows and rCart. * Transak API utilities shared across rFlows and rCart.
* *
* Handles access token management (cached 6 days) and * Builds Transak widget URLs using direct query parameters.
* widget URL generation via Transak's session API. * The gateway session API has auth issues, so we use the direct
* URL approach which Transak still supports.
*/ */
let _transakAccessToken: string | null = null; const TRANSAK_WIDGET_BASE = 'https://global.transak.com';
let _transakTokenExpiry = 0; const TRANSAK_WIDGET_BASE_STG = 'https://global-stg.transak.com';
export 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");
export function createTransakWidgetUrl(params: Record<string, string>): string {
const env = process.env.TRANSAK_ENV || 'PRODUCTION'; const env = process.env.TRANSAK_ENV || 'PRODUCTION';
const baseUrl = env === 'PRODUCTION' const base = env === 'PRODUCTION' ? TRANSAK_WIDGET_BASE : TRANSAK_WIDGET_BASE_STG;
? 'https://api.transak.com'
: 'https://api-stg.transak.com';
const res = await fetch(`${baseUrl}/partners/api/v2/refresh-token`, { const url = new URL(base);
method: 'POST', for (const [key, value] of Object.entries(params)) {
headers: { if (value != null && value !== '') {
'Content-Type': 'application/json', url.searchParams.set(key, value);
'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('[transak] Access token refreshed');
return _transakAccessToken;
}
export 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 url.toString();
return data.data?.widgetUrl;
} }